#include "NextHopRouter.h"
#include "Default.h"
#include "MeshTypes.h"
#include "Throttle.h"
#include "UptimeClock.h"
#include "meshUtils.h"
#if !MESHTASTIC_EXCLUDE_TRACEROUTE
#include "modules/TraceRouteModule.h"
#endif
#if HAS_TRAFFIC_MANAGEMENT
#include "modules/TrafficManagementModule.h"
#endif
#include "NodeDB.h"

#if USERPREFS_EVENT_MODE
static void capEventRelayHops(meshtastic_MeshPacket *packet)
{
    if (packet->hop_limit <= Default::eventModeRelayHopLimit)
        return;

    const uint8_t reduction = packet->hop_limit - Default::eventModeRelayHopLimit;
    packet->hop_start = reduction <= packet->hop_start ? packet->hop_start - reduction : 0;
    packet->hop_limit = Default::eventModeRelayHopLimit;
}
#endif

NextHopRouter::NextHopRouter() {}

bool NextHopRouter::relayOpaquePacket(const meshtastic_MeshPacket *p)
{
    // Opaque traffic is never admitted to PacketHistory, NodeDB, modules, phone, MQTT, or ACK
    // handling. Relay only from the immutable outer routing header and let hop exhaustion bound it.
    const auto mode = config.device.rebroadcast_mode;
    if (!iface || isToUs(p) || isFromUs(p) || p->id == 0 || p->hop_limit == 0 || !isRebroadcaster() || owner.is_licensed ||
        !IS_ONE_OF(mode, meshtastic_Config_DeviceConfig_RebroadcastMode_ALL,
                   meshtastic_Config_DeviceConfig_RebroadcastMode_ALL_SKIP_DECODING) ||
        (p->next_hop != NO_NEXT_HOP_PREFERENCE && p->next_hop != nodeDB->getLastByteOfNodeNum(getNodeNum())))
        return false;

    // Dedup opaque relays. Opaque frames deliberately never enter PacketHistory (so unauthenticated
    // traffic can't influence routing/ACK/next-hop) - but with NO dedup at all, a dense mesh re-relays
    // every copy of every frame, multiplying at each hop into an unbounded broadcast storm ("let hop
    // exhaustion bound it" caps depth, not count). Suppress duplicate opaque rebroadcasts with a small,
    // routing-isolated seen-set. Genuine originator (re)transmissions (hop_start == hop_limit) are
    // always relayed so reliable opaque unicast still propagates (mirrors FloodingRouter's isRepeated).
    const bool isOriginatorTx = p->hop_start > 0 && p->hop_start == p->hop_limit;
    if (opaqueWasSeenRecently(getFrom(p), p->id) && !isOriginatorTx) {
        LOG_TRACE("Drop duplicate opaque relay from 0x%08x id 0x%08x", getFrom(p), p->id);
        return false;
    }

    meshtastic_MeshPacket *relay = packetPool.allocCopy(*p);
    if (!relay)
        return false;
    relay->hop_limit--;
#if USERPREFS_EVENT_MODE
    capEventRelayHops(relay);
#endif
    relay->relay_node = nodeDB->getLastByteOfNodeNum(getNodeNum());
    // The interface declines some packets (NODENUM_BROADCAST_NO_LORA) with ERRNO_SHOULD_RELEASE,
    // which leaves the copy ours to free. Dropping it here would leak a pool slot per opaque frame.
    ErrorCode res = Router::send(relay);
    if (res == ERRNO_SHOULD_RELEASE)
        packetPool.release(relay);
    return res == ERRNO_OK;
}

// Isolated dedup for opaque relays (see relayOpaquePacket). Returns true if (from,id) is already in the
// ring; otherwise records it (round-robin eviction) and returns false. A separate table from
// PacketHistory on purpose: opaque frames must never influence routing/ACK/next-hop. No timestamps -
// a stale (from,id) can't false-match a later packet because ids are effectively random.
bool NextHopRouter::opaqueWasSeenRecently(NodeNum from, PacketId id)
{
    for (uint8_t i = 0; i < OPAQUE_SEEN_MAX; i++) {
        if (opaqueSeen[i].sender == from && opaqueSeen[i].id == id)
            return true;
    }
    // Not seen: record it, overwriting the oldest-written slot (FIFO). Empty slots hold id 0, which a
    // real entry never has (relayOpaquePacket drops id 0), so they simply never match above.
    opaqueSeen[opaqueSeenNext].sender = from;
    opaqueSeen[opaqueSeenNext].id = id;
    opaqueSeenNext = (uint8_t)((opaqueSeenNext + 1) % OPAQUE_SEEN_MAX);
    return false;
}

PendingPacket::PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions)
{
    packet = p;
    this->numRetransmissions = numRetransmissions - 1; // We subtract one, because we assume the user just did the first send
    this->initialNumRetransmissions = this->numRetransmissions;
}

/**
 * Send a packet
 */
ErrorCode NextHopRouter::send(meshtastic_MeshPacket *p)
{
    return sendWithNextHop(p, true);
}

ErrorCode NextHopRouter::sendWithNextHop(meshtastic_MeshPacket *p, bool trackRetransmission)
{
    // Add any messages _we_ send to the seen message list (so we will ignore all retransmissions we see)
    p->relay_node = nodeDB->getLastByteOfNodeNum(getNodeNum()); // First set the relayer to us
    wasSeenRecently(p);                                         // FIXME, move this to a sniffSent method

    p->next_hop = getNextHop(p->to, p->relay_node).value_or(NO_NEXT_HOP_PREFERENCE); // set the next hop
    LOG_TRACE("Set next hop for dest 0x%08x to 0x%x", p->to, p->next_hop);

    // If it's from us, ReliableRouter already handles retransmissions if want_ack is set. If a next hop is set and hop limit is
    // not 0 or want_ack is set, start retransmissions
    if (trackRetransmission && (!isFromUs(p) || !p->want_ack) && p->next_hop != NO_NEXT_HOP_PREFERENCE &&
        (p->hop_limit > 0 || p->want_ack)) {
        if (auto *copy = packetPool.allocCopy(*p))
            startRetransmission(copy); // start retransmission for relayed packet
    }

    return Router::send(p);
}

bool NextHopRouter::shouldFilterReceived(const meshtastic_MeshPacket *p)
{
    bool wasFallback = false;
    bool weWereNextHop = false;
    bool wasUpgraded = false;
    bool seenRecently = wasSeenRecently(p, true, &wasFallback, &weWereNextHop,
                                        &wasUpgraded); // Updates history; returns false when an upgrade is detected

    // Handle hop_limit upgrade scenario for rebroadcasters
    if (wasUpgraded && perhapsHandleUpgradedPacket(p)) {
        return true; // we handled it, so stop processing
    }

    if (seenRecently) {
        printPacket("Ignore dupe incoming msg", p);

        if (p->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA) {
            rxDupe++;
            stopRetransmission(p->from, p->id);
        }

        // If it was a fallback to flooding, try to relay again
        if (wasFallback) {
            LOG_INFO("Fallback to flooding from relay_node=0x%x", p->relay_node);
            // Check if it's still in the Tx queue, if not, we have to relay it again
            if (!findInTxQueue(p->from, p->id)) {
                if (reprocessPacket(p))
                    perhapsRebroadcast(p);
            }
        } else {
            bool isRepeated = getHopsAway(*p) == 0;
            // If repeated and not in Tx queue anymore, try relaying again, or if we are the destination, send the ACK again
            if (isRepeated) {
                if (!findInTxQueue(p->from, p->id)) {
                    if (reprocessPacket(p) && !isBlockedEventCoordinatePacket(p) && !perhapsRebroadcast(p) && isToUs(p) &&
                        p->want_ack) {
                        sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, p->channel, 0);
                    }
                }
            } else if (!weWereNextHop) {
                perhapsCancelDupe(p); // If it's a dupe, cancel relay if we were not explicitly asked to relay
            }
        }
        return true;
    }

    return Router::shouldFilterReceived(p);
}

void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtastic_Routing *c)
{
    NodeNum ourNodeNum = getNodeNum();
    uint8_t ourRelayID = nodeDB->getLastByteOfNodeNum(ourNodeNum);
    bool isAckorReply = (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) &&
                        (p->decoded.request_id != 0 || p->decoded.reply_id != 0);
    if (isAckorReply) {
        // Update next-hop for the original transmitter of this successful transmission to the relay node, but ONLY if "from"
        // is not 0 (means implicit ACK) and original packet was also relayed by this node, or we sent it directly to the
        // destination
        if (p->from != 0) {
            meshtastic_NodeInfoLite *origTx = nodeDB->getMeshNode(p->from);
            // Either relayer of ACK was also a relayer of the packet, or we were the *only* relayer and the ACK came
            // directly from the destination. checkRelayers is read-only on PacketHistory and O(1), so we run it even
            // when origTx is absent - that lets us still capture the confirmed hop into the TMM overflow cache below.
            // Single lookup for both relayer checks on the same (request_id, to) pair
            bool wasAlreadyRelayer = false;
            bool weWereSoleRelayer = false;
            bool weWereRelayer = false;
            checkRelayers(p->relay_node, ourRelayID, p->decoded.request_id, p->to, &wasAlreadyRelayer, &weWereRelayer,
                          &weWereSoleRelayer);
            if ((weWereRelayer && wasAlreadyRelayer) || (getHopsAway(*p) == 0 && weWereSoleRelayer)) {
                // M1/M2: only learn a next hop whose last byte maps to a single plausible relay. On a dense
                // mesh the byte may be ambiguous; storing it would aim future DMs at the wrong node. This gate
                // now protects BOTH the hot-store route (NodeInfoLite.next_hop) AND the TMM overflow cache -
                // the overflow cache deliberately holds many more next-hop bytes (long-tail nodes), so it is
                // even more collision-prone and must never store an ambiguous byte either. Ambiguous/unknown
                // -> store nothing and keep flooding (safe).
                if (nodeDB->resolveUniqueLastByte(p->relay_node, /*requireDirectNeighbor=*/false)) {
                    if (origTx && origTx->next_hop != p->relay_node) { // Not already set
                        LOG_INFO("Update next hop of 0x%08x to 0x%x from ACK/reply (was relayer %d we were sole %d)", p->from,
                                 p->relay_node, wasAlreadyRelayer, weWereSoleRelayer);
                        origTx->next_hop = p->relay_node;
                    }
                    noteRouteLearned(p->from, p->relay_node, millis()); // M3: anchor freshness (hot or overflow route)
#if HAS_TRAFFIC_MANAGEMENT
                    // Mirror the confirmed (and now unique-resolved) hop into the TMM overflow cache so it
                    // survives even when the source isn't (or is no longer) in the hot NodeDB.
                    if (trafficManagementModule)
                        trafficManagementModule->setNextHop(p->from, p->relay_node);
#endif
                } else {
                    LOG_DEBUG("Not learning next hop for 0x%08x: relay byte 0x%x ambiguous/unknown; keep flooding", p->from,
                              p->relay_node);
                }
            }
        }
        if (!isToUs(p)) {
            Router::cancelSending(p->to, p->decoded.request_id); // cancel rebroadcast for this DM
            // stop retransmission for the original packet
            stopRetransmission(p->to, p->decoded.request_id); // for original packet, from = to and id = request_id
        }
    }

    perhapsRebroadcast(p);

    // handle the packet as normal
    Router::sniffReceived(p, c);
}

/* Check if we should be rebroadcasting this packet if so, do so. */
bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p)
{
#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL
    // Never relay coordinate-bearing packets on the event ("everyone") channel.
    // Closes the reliable-retransmit-dupe path that runs before handleReceived().
    if (isBlockedEventCoordinatePacket(p)) {
        return false;
    }
#endif

    // Check if traffic management wants to exhaust this packet's hops
    bool exhaustHops = false;
#if HAS_TRAFFIC_MANAGEMENT
    if (trafficManagementModule && trafficManagementModule->shouldExhaustHops(*p)) {
        exhaustHops = true;
    }
#endif

    if (p->to == NODENUM_BROADCAST_NO_LORA)
        return false;

    // Allow rebroadcast if hop_limit > 0 OR if we're exhausting hops (which sets hop_limit = 0 but still needs one relay)
    if (!isToUs(p) && !isFromUs(p) && (p->hop_limit > 0 || exhaustHops)) {
        if (p->id != 0) {
            if (isRebroadcaster()) {
                // NOTE: this is a self-identity match (is the addressed next_hop OUR last byte?), so it
                // cannot be hardened with resolveLastByte() - a remote node that legitimately shares our
                // last byte will also match here and rebroadcast. That residual collision needs a wider
                // on-wire field to fix. M1/M2 instead shrink the blast radius by reducing how often an
                // ambiguous next_hop byte is ever learned (sniffReceived) or originated (getNextHop).
                if (p->next_hop == NO_NEXT_HOP_PREFERENCE || p->next_hop == nodeDB->getLastByteOfNodeNum(getNodeNum())) {
                    meshtastic_MeshPacket *tosend = packetPool.allocCopy(*p); // keep a copy because we will be sending it
                    if (!tosend)
                        return true;
                    LOG_INFO("Rebroadcast msg from %x", p->relay_node);

                    // If exhausting hops, force hop_limit = 0 regardless of other logic
                    if (exhaustHops) {
                        tosend->hop_limit = 0;
                        LOG_INFO("Traffic management: exhaust hops for 0x%08x, hop_limit=0", getFrom(p));
                    } else if (shouldDecrementHopLimit(p)) {
                        // Use shared logic to determine if hop_limit should be decremented
                        tosend->hop_limit--; // bump down the hop count
                    } else {
                        LOG_INFO("favorite-ROUTER/CLIENT_BASE-to-ROUTER/CLIENT_BASE rebroadcast: keep hop_limit");
                    }
#if USERPREFS_EVENT_MODE
                    capEventRelayHops(tosend);
#endif

                    ErrorCode res =
                        (p->next_hop == NO_NEXT_HOP_PREFERENCE) ? FloodingRouter::send(tosend) : NextHopRouter::send(tosend);
                    if (res == ERRNO_SHOULD_RELEASE)
                        packetPool.release(tosend);

                    return true;
                }
            } else {
                LOG_DEBUG("No rebroadcast: Role = CLIENT_MUTE or Rebroadcast Mode = NONE");
            }
        } else {
            LOG_DEBUG("Ignore 0 id broadcast");
        }
    }

    return false;
}

/**
 * Get the next hop for a destination, given the relay node
 * @return the node number of the next hop, 0 if no preference (fallback to FloodingRouter)
 */
std::optional<uint8_t> NextHopRouter::getNextHop(NodeNum to, uint8_t relay_node)
{
    if (isBroadcast(to))
        return std::nullopt;

    // Hot store first: a direct array hit on the live NodeDB entry.
    meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(to);
    if (node && node->next_hop) {
        // M3: proactively decay a stale or repeatedly-failing route back to flooding, so a dead hop
        // isn't trusted on the next DM's first (and on dense meshes, slowest) attempt. We only act on
        // a health record that still matches the stored byte; a next_hop set by another path (e.g.
        // TraceRouteModule) with no matching record is left authoritative.
        const RouteHealth *h = findRouteHealth(to);
        if (h && h->lastNextHop == node->next_hop && isRouteStale(*h, millis())) {
            LOG_INFO("Next hop 0x%x for 0x%08x stale (age/fails); flood and clear", node->next_hop, to);
            node->next_hop = NO_NEXT_HOP_PREFERENCE; // clear persisted route
            clearRouteHealth(to);                    // clear RAM health
            return std::nullopt;
        }

        // We are careful not to return the relay node as the next hop
        if (node->next_hop != relay_node) {
            // M1/M2: only emit a stored next_hop if its last byte still maps to a UNIQUE, currently
            // reachable direct neighbor. On a dense mesh the last byte collides, so an ambiguous byte
            // would unicast a hint toward the wrong physical node; if the neighbor has gone away we'd
            // unicast into a void. In both cases flood instead (managed flooding still delivers).
            ResolvedNode r = nodeDB->resolveLastByte(node->next_hop, /*requireDirectNeighbor=*/true);
            if (r.status == LastByteResolution::Unique)
                return node->next_hop;
            LOG_WARN("Next hop 0x%x for 0x%08x %s; set no pref", node->next_hop, to,
                     r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "not a known neighbor");
        } else
            LOG_WARN("Next hop for 0x%08x is 0x%x, same as relayer; set no pref", to, node->next_hop);
    }

#if HAS_TRAFFIC_MANAGEMENT
    // Fallback: TMM overflow cache holds confirmed hops for nodes that have aged out of the hot store.
    // It is the same byte source/confidence as NodeInfoLite.next_hop, so it gets the same M1/M2/M3
    // protection: decay a stale/failing route, then only emit a byte that still resolves to a unique
    // reachable neighbor. Without this the overflow cache (which holds MORE bytes for MORE nodes) would
    // reintroduce exactly the silent-misroute that M1/M2 closes on the hot path.
    if (trafficManagementModule) {
        uint8_t hint = trafficManagementModule->getNextHopHint(to);
        if (hint && hint != relay_node) {
            const RouteHealth *h = findRouteHealth(to);
            if (h && h->lastNextHop == hint && isRouteStale(*h, millis())) {
                LOG_INFO("TMM next hop 0x%x for 0x%08x stale (age/fails); flood and clear", hint, to);
                trafficManagementModule->clearNextHop(to); // clear overflow route (setNextHop won't store 0)
                clearRouteHealth(to);                      // clear RAM health
                return std::nullopt;
            }
            ResolvedNode r = nodeDB->resolveLastByte(hint, /*requireDirectNeighbor=*/true);
            if (r.status == LastByteResolution::Unique) {
                LOG_TRACE("Next hop for 0x%08x is 0x%x (TMM cache)", to, hint);
                return hint;
            }
            LOG_WARN("TMM next hop 0x%x for 0x%08x %s; set no pref", hint, to,
                     r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "not a known neighbor");
        }
    }
#endif

    return std::nullopt;
}

PendingPacket *NextHopRouter::findPendingPacket(GlobalPacketId key)
{
    auto old = pending.find(key); // If we have an old record, someone messed up because id got reused
    if (old != pending.end()) {
        return &old->second;
    } else
        return NULL;
}

/**
 * Stop any retransmissions we are doing of the specified node/packet ID pair
 */
bool NextHopRouter::stopRetransmission(NodeNum from, PacketId id)
{
    auto key = GlobalPacketId(from, id);
    return stopRetransmission(key);
}

bool NextHopRouter::roleAllowsCancelingFromTxQueue(const meshtastic_MeshPacket *p)
{
    // Return true if we're allowed to cancel a packet in the txQueue (so we may never transmit it even once)

    // Return false for roles like ROUTER, ROUTER_LATE which should always transmit the packet at least once.

    return roleAllowsCancelingDupe(p); // same logic as FloodingRouter::roleAllowsCancelingDupe
}

bool NextHopRouter::stopRetransmission(GlobalPacketId key)
{
    auto old = findPendingPacket(key);
    if (old) {
        auto p = old->packet;
        /* Only when we already transmitted a packet via LoRa, we will cancel the packet in the Tx queue
          to avoid canceling a transmission if it was ACKed super fast via MQTT */
        if (old->numRetransmissions < old->initialNumRetransmissions) {
            // We only cancel it if we are the original sender or if we're not a router(_late)
            if (isFromUs(p) || roleAllowsCancelingFromTxQueue(p)) {
                // remove the 'original' (identified by originator and packet->id) from the txqueue and free it
                cancelSending(getFrom(p), p->id);
            }
        }

        // Regardless of whether or not we canceled this packet from the txQueue, remove it from our pending list so it
        // doesn't get scheduled again. (This is the core of stopRetransmission.)
        auto numErased = pending.erase(key);
        assert(numErased == 1);

        // When we remove an entry from pending, always be sure to release the copy of the packet that was allocated in the
        // call to startRetransmission.
        packetPool.release(p);

        return true;
    } else
        return false;
}

/**
 * Add p to the list of packets to retransmit occasionally.  We will free it once we stop retransmitting.
 */
PendingPacket *NextHopRouter::startRetransmission(meshtastic_MeshPacket *p, uint8_t numReTx)
{
    auto id = GlobalPacketId(p);
    auto rec = PendingPacket(p, numReTx);

    stopRetransmission(getFrom(p), p->id);

    setNextTx(&rec);
    pending[id] = rec;

    return &pending[id];
}

/**
 * Do any retransmissions that are scheduled (FIXME - for the time being called from loop)
 */
int32_t NextHopRouter::doRetransmissions()
{
    // Same clock Throttle reads, so setNextTx() deadlines and this test can't diverge under an
    // injected test clock.
    uint32_t now = Time::getMillis();
    int32_t d = INT32_MAX;

    // FIXME, we should use a better datastructure rather than walking through this map.
    // for(auto el: pending) {
    for (auto it = pending.begin(), nextIt = it; it != pending.end(); it = nextIt) {
        ++nextIt; // we use this odd pattern because we might be deleting it...
        auto &p = it->second;

        bool stillValid = true; // assume we'll keep this record around

        // Judged against the snapshot above, so one pass sees one instant and the 49.7 day wrap
        // can't stall retransmission.
        if (Throttle::deadlinePassedAt(now, p.nextTxMsec)) {
            if (p.numRetransmissions == 0) {
                if (isFromUs(p.packet)) {
                    LOG_DEBUG("Reliable send failed, return nak fr=0x%08x,to=0x%08x,id=0x%08x", p.packet->from, p.packet->to,
                              p.packet->id);
                    sendAckNak(meshtastic_Routing_Error_MAX_RETRANSMIT, getFrom(p.packet), p.packet->id, p.packet->channel);
                }
                // Note: we don't stop retransmission here, instead the Nak packet gets processed in sniffReceived
                stopRetransmission(it->first);
                stillValid = false; // just deleted it
            } else {
                LOG_DEBUG("Send retransmission fr=0x%08x,to=0x%08x,id=0x%08x, tries left=%d", p.packet->from, p.packet->to,
                          p.packet->id, p.numRetransmissions);

                if (!isBroadcast(p.packet->to)) {
                    if (p.numRetransmissions == 1) {
                        // Last retransmission: this directed delivery went un-ACKed. Record the failure
                        // (M3 - accumulates across DMs to age out a flapping/dead route) and reset
                        // next_hop so the final try falls back to FloodingRouter.
                        noteRouteFailure(p.packet->to);
                        p.packet->next_hop = NO_NEXT_HOP_PREFERENCE;
                        // Also reset it in the nodeDB
                        meshtastic_NodeInfoLite *sentTo = nodeDB->getMeshNode(p.packet->to);
                        if (sentTo) {
                            LOG_INFO("Reset next hop for dest 0x%08x", p.packet->to);
                            sentTo->next_hop = NO_NEXT_HOP_PREFERENCE;
                        }
#if HAS_TRAFFIC_MANAGEMENT
                        if (trafficManagementModule) {
                            trafficManagementModule->clearNextHop(p.packet->to);
                        }
#endif
                        if (auto *copy = packetPool.allocCopy(*p.packet)) {
                            if (FloodingRouter::send(copy) == ERRNO_SHOULD_RELEASE)
                                packetPool.release(copy);
                        }
                    } else {
#if NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED
                        // M4 (gated): if the route isn't proven healthy, don't spend a second directed
                        // attempt - start flooding one retry sooner to cut recovery latency. A verified
                        // route (fresh, zero recent failures) keeps the unchanged directed-retry path so
                        // the sparse-mesh happy path is untouched.
                        RouteHealth *h = findRouteHealth(p.packet->to);
                        bool verified = h && h->consecutiveFailures == 0 && !isRouteStale(*h, now);
                        if (!verified) {
                            p.packet->next_hop = NO_NEXT_HOP_PREFERENCE;
                            meshtastic_NodeInfoLite *sentTo = nodeDB->getMeshNode(p.packet->to);
                            if (sentTo)
                                sentTo->next_hop = NO_NEXT_HOP_PREFERENCE;
                            if (auto *copy = packetPool.allocCopy(*p.packet)) {
                                if (FloodingRouter::send(copy) == ERRNO_SHOULD_RELEASE)
                                    packetPool.release(copy);
                            }
                        } else {
                            if (auto *copy = packetPool.allocCopy(*p.packet)) {
                                if (sendWithNextHop(copy, false) == ERRNO_SHOULD_RELEASE)
                                    packetPool.release(copy);
                            }
                        }
#else
                        if (auto *copy = packetPool.allocCopy(*p.packet)) {
                            if (sendWithNextHop(copy, false) == ERRNO_SHOULD_RELEASE)
                                packetPool.release(copy);
                        }
#endif
                    }
                } else {
                    // Note: we call the superclass version because we don't want to have our version of send() add a new
                    // retransmission record
                    if (auto *copy = packetPool.allocCopy(*p.packet)) {
                        if (FloodingRouter::send(copy) == ERRNO_SHOULD_RELEASE)
                            packetPool.release(copy);
                    }
                }

                // Queue again
                --p.numRetransmissions;
                setNextTx(&p);
            }
        }

        if (stillValid) {
            // Update our desired sleep delay
            int32_t t = p.nextTxMsec - now;

            d = min(t, d);
        }
    }

    return d;
}

void NextHopRouter::setNextTx(PendingPacket *pending)
{
    assert(iface);
    auto d = iface->getRetransmissionMsec(pending->packet);
    pending->nextTxMsec = Time::getMillis() + d;
    LOG_TRACE("Next retransmission in %u msecs", d);
    printPacket("", pending->packet);
    setReceivedMessage(); // Run ASAP, so we can figure out our correct sleep time
}

// ---------------------------------------------------------------------------
// M3: RAM route-health table. Bounded array with reuse-oldest eviction (same discipline as
// PacketHistory). All age comparisons use unsigned subtraction so they survive the 49.7-day millis()
// rollover. dest == 0 marks an empty slot; learnedAtMsec is normalized to 1 on write so an occupied
// slot is never read as infinitely old.
// ---------------------------------------------------------------------------

RouteHealth *NextHopRouter::findRouteHealth(NodeNum dest)
{
    if (dest == 0)
        return nullptr;
    for (auto &h : routeHealth)
        if (h.dest == dest)
            return &h;
    return nullptr;
}

RouteHealth *NextHopRouter::getOrAllocRouteHealth(NodeNum dest, uint32_t now)
{
    if (dest == 0)
        return nullptr;

    RouteHealth *oldest = &routeHealth[0];
    RouteHealth *freeSlot = nullptr;
    for (auto &h : routeHealth) {
        if (h.dest == dest)
            return &h; // existing record
        if (h.dest == 0) {
            if (!freeSlot)
                freeSlot = &h; // remember the first free slot; prefer it over evicting
            continue;
        }
        // Track the oldest occupied slot in case the table is full (rollover-safe).
        if ((uint32_t)(now - h.learnedAtMsec) > (uint32_t)(now - oldest->learnedAtMsec))
            oldest = &h;
    }
    // Claim the free slot if there is one, else reuse the oldest. Reset before use and stamp the dest
    // so the record is findable.
    RouteHealth *slot = freeSlot ? freeSlot : oldest;
    *slot = RouteHealth{};
    slot->dest = dest;
    return slot;
}

void NextHopRouter::noteRouteLearned(NodeNum dest, uint8_t nextHop, uint32_t now)
{
    if (dest == 0 || nextHop == NO_NEXT_HOP_PREFERENCE)
        return;
    RouteHealth *h = getOrAllocRouteHealth(dest, now);
    if (!h)
        return;
    // A genuinely new next hop earns a clean slate; re-learning the SAME hop keeps the accumulated
    // failure count so an asymmetric reverse path that keeps re-teaching a dead forward hop still ages
    // out instead of resetting the counter every time.
    if (h->lastNextHop != nextHop) {
        h->lastNextHop = nextHop;
        h->consecutiveFailures = 0;
    }
    h->learnedAtMsec = now ? now : 1;
}

void NextHopRouter::noteRouteSuccess(NodeNum dest, uint32_t now)
{
    RouteHealth *h = findRouteHealth(dest);
    if (!h)
        return; // only routes we actually learned have health to refresh
    h->consecutiveFailures = 0;
    h->learnedAtMsec = now ? now : 1;
}

void NextHopRouter::noteRouteFailure(NodeNum dest)
{
    RouteHealth *h = findRouteHealth(dest);
    if (!h)
        return; // nothing to penalize (we were flooding, or never learned a route here)
    if (h->consecutiveFailures < 255)
        h->consecutiveFailures++;
}

bool NextHopRouter::isRouteStale(const RouteHealth &h, uint32_t now) const
{
    if (h.consecutiveFailures >= ROUTE_FAILURE_THRESHOLD)
        return true;
    return (uint32_t)(now - h.learnedAtMsec) >= ROUTE_TTL_MSEC;
}

void NextHopRouter::clearRouteHealth(NodeNum dest)
{
    RouteHealth *h = findRouteHealth(dest);
    if (h)
        *h = RouteHealth{};
}
