/*
 *  Copyright (C) 2004-2025 Savoir-faire Linux Inc.
 *
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with this program. If not, see <https://www.gnu.org/licenses/>.
 */
#include "upnp/upnp_context.h"
#include "upnp/mapping.h"

#include "protocol/upnp_protocol.h"
#if HAVE_LIBNATPMP
#include "protocol/natpmp/nat_pmp.h"
#endif
#if HAVE_LIBUPNP
#include "protocol/pupnp/pupnp.h"
#endif
#include <asio.hpp>
#include <asio/steady_timer.hpp>
#if __has_include(<fmt/std.h>)
#include <fmt/std.h>
#else
#include <fmt/ostream.h>
#endif
#include <fmt/chrono.h>

namespace dhtnet {
namespace upnp {

constexpr static auto MAPPING_RENEWAL_THROTTLING_DELAY = std::chrono::seconds(10);
constexpr static auto MAPPING_EXPIRY_MINIMAL_LIFETIME = std::chrono::hours(1);
constexpr static int MAX_REQUEST_RETRIES = 20;
constexpr static int MAX_REQUEST_REMOVE_COUNT = 10;

constexpr static uint16_t UPNP_TCP_PORT_MIN {10000};
constexpr static uint16_t UPNP_TCP_PORT_MAX {UPNP_TCP_PORT_MIN + 5000};
constexpr static uint16_t UPNP_UDP_PORT_MIN {20000};
constexpr static uint16_t UPNP_UDP_PORT_MAX {UPNP_UDP_PORT_MIN + 5000};

UPnPContext::UPnPContext(const std::shared_ptr<asio::io_context>& ioContext,
                         const std::shared_ptr<dht::log::Logger>& logger,
                         std::unique_ptr<std::mt19937_64>&& rng)
    : rng_(rng ? std::move(*rng) : dht::crypto::getSeededRandomEngine<std::mt19937_64>())
    , stateCtx(createIoContext(ioContext, stateContextRunner_, logger))
#if HAVE_LIBUPNP
    , upnpCtx(createIoContext(nullptr, upnpContextRunner_, logger))
#endif
#if HAVE_LIBNATPMP
    , natPmpCtx(createIoContext(nullptr, natPmpContextRunner_, logger))
#endif
    , logger_(logger)
    , connectivityChangedTimer_(*stateCtx)
    , mappingRenewalTimer_(*stateCtx)
    , renewalSchedulingTimer_(*stateCtx)
    , syncTimer_(*stateCtx)
    , igdDiscoveryTimer_(*stateCtx)
    , mappingLabel_(Mapping::DEFAULT_UPNP_MAPPING_DESCRIPTION_PREFIX)
{
    if (logger_)
        logger_->debug("Creating UPnPContext instance [{}]", fmt::ptr(this));

    // Set port ranges
    portRange_.emplace(PortType::TCP, std::make_pair(UPNP_TCP_PORT_MIN, UPNP_TCP_PORT_MAX));
    portRange_.emplace(PortType::UDP, std::make_pair(UPNP_UDP_PORT_MIN, UPNP_UDP_PORT_MAX));

    asio::post(*stateCtx, [this] { init(); });
}

std::shared_ptr<asio::io_context>
UPnPContext::createIoContext(const std::shared_ptr<asio::io_context>& ctx,
                             std::unique_ptr<std::thread>& ioContextRunner,
                             const std::shared_ptr<dht::log::Logger>& logger)
{
    if (ctx) {
        return ctx;
    } else {
        if (logger)
            logger->debug("UPnPContext: Starting dedicated io_context thread");
        auto ioCtx = std::make_shared<asio::io_context>();
        ioContextRunner = std::make_unique<std::thread>([ioCtx, l = logger]() {
            try {
                auto work = asio::make_work_guard(*ioCtx);
                ioCtx->run();
            } catch (const std::exception& ex) {
                if (l)
                    l->error("Unexpected io_context thread exception: {}", ex.what());
            }
        });
        return ioCtx;
    }
}

void
UPnPContext::shutdown(std::condition_variable& cv)
{
    if (logger_)
        logger_->debug("Shutdown UPnPContext instance [{}]", fmt::ptr(this));

    stopUpnp(true);

#if HAVE_LIBUPNP
    std::promise<void> ioFlushed;
    asio::post(*upnpCtx, [&ioFlushed] { ioFlushed.set_value(); });
#endif
#if HAVE_LIBNATPMP
    std::promise<void> natPmpFlushed;
    asio::post(*natPmpCtx, [&natPmpFlushed] { natPmpFlushed.set_value(); });
#endif

#if HAVE_LIBUPNP
    if (ioFlushed.get_future().wait_for(std::chrono::seconds(5)) == std::future_status::timeout) {
        if (logger_)
            logger_->warn("Timed out waiting for pending UPnP operations to complete");
    }
#endif
#if HAVE_LIBNATPMP
    if (natPmpFlushed.get_future().wait_for(std::chrono::seconds(5)) == std::future_status::timeout) {
        if (logger_)
            logger_->warn("Timed out waiting for pending NAT-PMP operations to complete");
    }
#endif

    for (auto const& [_, proto] : protocolList_) {
        proto->terminate();
    }

    std::lock_guard lock(mappingMutex_);
    mappingList_->clear();
    controllerList_.clear();
    protocolList_.clear();
    shutdownComplete_ = true;
    if (shutdownTimedOut_) {
        // If we timed out in shutdown(), then calling notify_one is not necessary,
        // and doing so anyway can cause bugs, see:
        // https://git.jami.net/savoirfairelinux/dhtnet/-/issues/28
        return;
    }
    cv.notify_one();
}

void
UPnPContext::shutdownIoContext(const std::shared_ptr<asio::io_context>& context,
                               std::unique_ptr<std::thread>& contextRunner,
                               const char* name)
{
    if (!contextRunner)
        return;

    if (logger_)
        logger_->debug("UPnPContext: Stopping {} io_context thread {}", name, fmt::ptr(this));
    context->stop();
    contextRunner->join();
    contextRunner.reset();
    if (logger_)
        logger_->debug("UPnPContext: Stopping {} io_context thread - finished {}", name, fmt::ptr(this));
}

void
UPnPContext::shutdown()
{
    std::unique_lock lk(mappingMutex_);
    std::condition_variable cv;

    asio::post(*stateCtx, [&, this] { shutdown(cv); });

    if (logger_)
        logger_->debug("Waiting for shutdown…");

    if (cv.wait_for(lk, std::chrono::seconds(30), [this] { return shutdownComplete_; })) {
        if (logger_)
            logger_->debug("Shutdown completed");
    } else {
        if (logger_)
            logger_->error("Shutdown timed-out");
        shutdownTimedOut_ = true;
    }
    // NOTE: It's important to unlock mappingMutex_ here, otherwise we get a
    // deadlock when the call to cv.wait_for() above times out before we return
    // from proto->terminate() in shutdown(cv).
    lk.unlock();

#if HAVE_LIBUPNP
    shutdownIoContext(upnpCtx, upnpContextRunner_, "PUPnP");
#endif
#if HAVE_LIBNATPMP
    shutdownIoContext(natPmpCtx, natPmpContextRunner_, "NAT-PMP");
#endif
    shutdownIoContext(stateCtx, stateContextRunner_, "state");
}

UPnPContext::~UPnPContext()
{
    if (logger_)
        logger_->debug("UPnPContext instance [{}] destroyed", fmt::ptr(this));
}

void
UPnPContext::init()
{
#if HAVE_LIBNATPMP
    auto natPmp = std::make_shared<NatPmp>(natPmpCtx, logger_);
    natPmp->setObserver(this);
    protocolList_.emplace(NatProtocolType::NAT_PMP, std::move(natPmp));
#endif

#if HAVE_LIBUPNP
    auto pupnp = std::make_shared<PUPnP>(upnpCtx, logger_);
    pupnp->setObserver(this);
    protocolList_.emplace(NatProtocolType::PUPNP, std::move(pupnp));
#endif
}

asio::io_context&
UPnPContext::protocolContext(NatProtocolType type)
{
#if HAVE_LIBNATPMP && HAVE_LIBUPNP
    return type == NatProtocolType::NAT_PMP ? *natPmpCtx : *upnpCtx;
#elif HAVE_LIBNATPMP
    assert(type == NatProtocolType::NAT_PMP);
    return *natPmpCtx;
#elif HAVE_LIBUPNP
    assert(type == NatProtocolType::PUPNP);
    return *upnpCtx;
#else
    (void) type;
    return *stateCtx;
#endif
}

void
UPnPContext::startUpnp()
{
    assert(not controllerList_.empty());

    if (logger_)
        logger_->debug("Starting UPnP context");

    // Request a new IGD search.
    for (auto const& [type, protocol] : protocolList_) {
        asio::dispatch(protocolContext(type), [p = protocol] { p->searchForIgd(); });
    }

    started_ = true;
}

void
UPnPContext::stopUpnp(bool shuttingDown)
{
    if (logger_)
        logger_->debug("Stopping UPnP context");

    connectivityChangedTimer_.cancel();
    mappingRenewalTimer_.cancel();
    renewalSchedulingTimer_.cancel();
    syncTimer_.cancel();
    syncRequested_ = false;

    // Clear all current mappings

    // Use a temporary list to avoid processing the mappings while holding the lock.
    std::list<Mapping::sharedPtr_t> toRemoveList;
    {
        std::lock_guard lock(mappingMutex_);

        PortType types[2] {PortType::TCP, PortType::UDP};
        for (auto& type : types) {
            const auto& mappingList = getMappingList(type);
            for (const auto& [_, map] : mappingList) {
                toRemoveList.emplace_back(map);
            }
        }
    }
    for (auto const& map : toRemoveList) {
        // TODO: Preserving mappings prevents blocking calls that slow down shutdown
        auto preserveForReuse = shuttingDown && map->getProtocol() == NatProtocolType::PUPNP;
        if (!preserveForReuse) {
            requestRemoveMapping(map);
        } else if (logger_) {
            logger_->debug("Preserving PUPnP mapping {} for reuse on next session", map->toString());
        }

        if (map->getAutoUpdate() && !shuttingDown) {
            // Set the mapping's state to PENDING so that it
            // gets recreated if we restart UPnP later.
            map->setState(MappingState::PENDING);
        } else {
            unregisterMapping(map);
        }
    }
    {
        // Invalidate the current IGD.
        std::lock_guard lock(mappingMutex_);
        currentIgd_.reset();
    }

    // During shutdown, terminate() releases protocol resources. For a regular stop after the
    // last controller is removed, protocol instances stay alive and must release sockets here.
    auto releaseProtocolResources = controllerList_.empty();

    // Clear all current IGDs.
    for (auto const& [type, protocol] : protocolList_) {
        asio::dispatch(protocolContext(type),
                       [p = protocol, releaseProtocolResources] { p->clearIgds(releaseProtocolResources); });
    }

    started_ = false;
}

uint16_t
UPnPContext::generateRandomPort(std::mt19937_64& gen, PortType type)
{
    auto minPort = type == PortType::TCP ? UPNP_TCP_PORT_MIN : UPNP_UDP_PORT_MIN;
    auto maxPort = type == PortType::TCP ? UPNP_TCP_PORT_MAX : UPNP_UDP_PORT_MAX;
    return std::uniform_int_distribution<uint16_t>(minPort, maxPort)(gen);
}

void
UPnPContext::connectivityChanged()
{
    // Debounce the connectivity change notification.
    connectivityChangedTimer_.expires_after(std::chrono::milliseconds(50));
    connectivityChangedTimer_.async_wait(std::bind(&UPnPContext::_connectivityChanged, this, std::placeholders::_1));
}

void
UPnPContext::_connectivityChanged(const asio::error_code& ec)
{
    if (ec == asio::error::operation_aborted)
        return;

    auto hostAddr = ip_utils::getLocalAddr(AF_INET);

    if (logger_)
        logger_->debug("Connectivity change check: Host address {}", hostAddr.toString());

    auto restartUpnp = false;

    // On reception of "connectivity change" notification, the UPNP search
    // will be restarted if either there is no valid IGD, or the IGD address
    // changed.

    if (not isReady()) {
        restartUpnp = true;
    } else {
        // Check if the host address changed.
        for (auto const& [_, protocol] : protocolList_) {
            if (protocol->isReady() and hostAddr != protocol->getHostAddress()) {
                if (logger_)
                    logger_->warn("Host address changed from {} to {}",
                                  protocol->getHostAddress().toString(),
                                  hostAddr.toString());
                protocol->clearIgds();
                restartUpnp = true;
                break;
            }
        }
    }

    // We have at least one valid IGD and the host address did
    // not change, so no need to restart.
    if (not restartUpnp) {
        return;
    }

    // No registered controller. A new search will be performed when
    // a controller is registered.
    if (controllerList_.empty())
        return;

    if (logger_)
        logger_->debug("Connectivity changed. Clear the IGDs and restart");

    stopUpnp();
    startUpnp();
}

void
UPnPContext::setPublicAddress(const IpAddr& addr)
{
    if (not addr)
        return;

    std::lock_guard lock(publicAddressMutex_);
    if (knownPublicAddress_ != addr) {
        knownPublicAddress_ = std::move(addr);
        if (logger_)
            logger_->debug("Setting the known public address to {}", addr.toString());
    }
}

bool
UPnPContext::isReady() const
{
    std::lock_guard lock(mappingMutex_);
    return currentIgd_ ? true : false;
}

IpAddr
UPnPContext::getExternalIP() const
{
    std::lock_guard lock(mappingMutex_);
    if (currentIgd_)
        return currentIgd_->getPublicIp();
    return {};
}

Mapping::sharedPtr_t
UPnPContext::reserveMapping(Mapping& requestedMap)
{
    auto desiredPort = requestedMap.getExternalPort();

    if (desiredPort == 0) {
        if (logger_)
            logger_->debug("Desired port is not set, will provide the first available port for [{}]",
                           requestedMap.getTypeStr());
    } else {
        if (logger_)
            logger_->debug("Attempt to find mapping for port {:d} [{}]", desiredPort, requestedMap.getTypeStr());
    }

    Mapping::sharedPtr_t mapRes;

    {
        std::lock_guard lock(mappingMutex_);
        const auto& mappingList = getMappingList(requestedMap.getType());

        // We try to provide a mapping in "OPEN" state. If not found,
        // we provide any available mapping. In this case, it's up to
        // the caller to use it or not.
        for (auto const& [_, map] : mappingList) {
            // If the desired port is null, we pick the first available port.
            if (map->isValid() and (desiredPort == 0 or map->getExternalPort() == desiredPort) and map->isAvailable()) {
                // Considere the first available mapping regardless of its
                // state. A mapping with OPEN state will be used if found.
                if (not mapRes)
                    mapRes = map;

                if (map->getState() == MappingState::OPEN) {
                    // Found an "OPEN" mapping. We are done.
                    mapRes = map;
                    // Make the mapping unavailable while we're holding the lock on
                    // mappingMutex_ to ensure no other thread will try to use it.
                    mapRes->setAvailable(false);
                    break;
                }
            }
        }
    }

    // Create a mapping if none was available.
    if (not mapRes) {
        mapRes = registerMapping(requestedMap, /* available */ false);
    }

    if (mapRes) {
        // Copy attributes.
        mapRes->setNotifyCallback(requestedMap.getNotifyCallback());
        mapRes->enableAutoUpdate(requestedMap.getAutoUpdate());
        // Notify the listener.
        Mapping::notify(mapRes);
    }

    enforceAvailableMappingsLimits();

    return mapRes;
}

void
UPnPContext::releaseMapping(const Mapping& map)
{
    asio::dispatch(*stateCtx, [this, map] {
        if (shutdownComplete_)
            return;
        auto mapPtr = getMappingWithKey(map.getMapKey());

        if (not mapPtr) {
            // Might happen if the mapping failed or was never granted.
            if (logger_)
                logger_->debug("Mapping {} does not exist or was already removed", map.toString());
            return;
        }

        if (mapPtr->isAvailable()) {
            if (logger_)
                logger_->warn("Attempting to release an unused mapping {}", mapPtr->toString());
            return;
        }

        // Reset the mapping options: disable auto-update and remove the notify callback.
        // This is important because the mapping will be available again and can be reused
        // by another (or the same) controller which may have different preferences.
        // The notify callback is also removed to avoid calling it when the mapping is not used anymore.
        mapPtr->setNotifyCallback(nullptr);
        mapPtr->enableAutoUpdate(false);
        mapPtr->setAvailable(true);
        if (logger_)
            logger_->debug("Mapping {} released", mapPtr->toString());
        enforceAvailableMappingsLimits();
    });
}

void
UPnPContext::registerController(void* controller)
{
    {
        std::lock_guard lock(mappingMutex_);
        if (shutdownComplete_) {
            if (logger_)
                logger_->warn("UPnPContext already shutdown");
            return;
        }
        auto ret = controllerList_.emplace(controller);
        if (not ret.second) {
            if (logger_)
                logger_->warn("Controller {} is already registered", fmt::ptr(controller));
            return;
        }
    }

    if (logger_)
        logger_->debug("Successfully registered controller {}", fmt::ptr(controller));
    if (not started_)
        startUpnp();
}

void
UPnPContext::unregisterController(void* controller)
{
    if (shutdownComplete_)
        return;
    std::unique_lock lock(mappingMutex_);
    if (controllerList_.erase(controller) == 1) {
        if (logger_)
            logger_->debug("Successfully unregistered controller {}", fmt::ptr(controller));
    } else {
        if (logger_)
            logger_->debug("Controller {} was already removed", fmt::ptr(controller));
    }

    if (controllerList_.empty()) {
        lock.unlock();
        stopUpnp();
    }
}

std::vector<IGDInfo>
UPnPContext::getIgdsInfo() const
{
    std::vector<IGDInfo> igdInfoList;

    for (const auto& [_, protocol] : protocolList_) {
        for (auto& igd : protocol->getIgdList()) {
            IGDInfo info;
            info.uid = igd->getUID();
            info.localIp = igd->getLocalIp();
            info.publicIp = igd->getPublicIp();
            info.mappingInfoList = protocol->getMappingsInfo(igd);

            igdInfoList.push_back(std::move(info));
        }
    }

    return igdInfoList;
}

// TODO: refactor this function so that it can never fail unless there are literally no ports available
uint16_t
UPnPContext::getAvailablePortNumber(PortType type)
{
    // Only return an available random port. No actual
    // reservation is made here.

    std::lock_guard lock(mappingMutex_);
    const auto& mappingList = getMappingList(type);
    int tryCount = 0;
    while (tryCount++ < MAX_REQUEST_RETRIES) {
        uint16_t port = generateRandomPort(rng_, type);
        Mapping map(type, port, port);
        if (mappingList.find(map.getMapKey()) == mappingList.end())
            return port;
    }

    // Very unlikely to get here.
    if (logger_)
        logger_->error("Unable to find an available port after {} attempt(s)", MAX_REQUEST_RETRIES);
    return 0;
}

void
UPnPContext::requestMapping(const Mapping::sharedPtr_t& map)
{
    assert(map);
    auto const& igd = getCurrentIgd();
    // We must have at least a valid IGD pointer if we get here.
    // Note that this method is called only if there was a valid IGD, but
    // because the processing is asynchronous, there may no longer
    // be one by the time this code executes.
    if (not igd) {
        if (logger_)
            logger_->debug("Unable to request mapping {}: no valid IGDs available", map->toString());
        return;
    }

    map->setIgd(igd);

    if (logger_)
        logger_->debug("Request mapping {} using protocol [{}] IGD [{}]",
                       map->toString(),
                       igd->getProtocolName(),
                       igd->toString());

    updateMappingState(map, MappingState::IN_PROGRESS);

    auto const& protocol = protocolList_.at(igd->getProtocol());
    protocol->requestMappingAdd(*map);
}

void
UPnPContext::provisionNewMappings(PortType type, int portCount)
{
    if (logger_)
        logger_->debug("Provision {:d} new mappings of type [{}]", portCount, Mapping::getTypeStr(type));

    while (portCount > 0) {
        auto port = getAvailablePortNumber(type);
        if (port > 0) {
            // Found an available port number
            portCount--;
            Mapping map(type, port, port, true);
            registerMapping(map);
        } else {
            // Very unlikely to get here!
            if (logger_)
                logger_->error("Unable to provision port: No available port number");
            return;
        }
    }
}

void
UPnPContext::deleteUnneededMappings(PortType type, int portCount)
{
    if (logger_)
        logger_->debug("Remove {:d} unneeded mapping(s) of type [{}]", portCount, Mapping::getTypeStr(type));

    std::lock_guard lock(mappingMutex_);
    auto& mappingList = getMappingList(type);

    for (auto it = mappingList.begin(); it != mappingList.end();) {
        auto map = it->second;
        assert(map);

        if (not map->isAvailable()) {
            it++;
            continue;
        }

        if (map->getState() == MappingState::OPEN and portCount > 0) {
            // Close portCount mappings in "OPEN" state.
            requestRemoveMapping(map);
            it = mappingList.erase(it);
            portCount--;
        } else if (map->getState() != MappingState::OPEN) {
            // If this method is called, it means there are more open
            // mappings than required. So, all mappings in a state other
            // than "OPEN" state (typically in in-progress state) will
            // be deleted as well.
            it = mappingList.erase(it);
        } else {
            it++;
        }
    }
}

void
UPnPContext::updateCurrentIgd()
{
    std::lock_guard lock(mappingMutex_);
    if (currentIgd_ and currentIgd_->isValid()) {
        if (logger_)
            logger_->debug("Current IGD is still valid, no need to update");
        return;
    }

    // Reset and search for the best IGD.
    currentIgd_.reset();

    for (auto const& [_, protocol] : protocolList_) {
        if (protocol->isReady()) {
            auto igdList = protocol->getIgdList();
            assert(not igdList.empty());
            auto const& igd = igdList.front();
            if (not igd->isValid())
                continue;

            // Prefer NAT-PMP over PUPnP.
            if (currentIgd_ and igd->getProtocol() != NatProtocolType::NAT_PMP)
                continue;

            // Update.
            currentIgd_ = igd;
        }
    }

    if (currentIgd_ and currentIgd_->isValid()) {
        if (logger_)
            logger_->debug("Current IGD updated to [{}] IGD [{} {}] ",
                           currentIgd_->getProtocolName(),
                           currentIgd_->getUID(),
                           currentIgd_->toString());
    } else {
        if (logger_)
            logger_->warn("Unable to update current IGD: No valid IGD was found");
    }
}

std::shared_ptr<IGD>
UPnPContext::getCurrentIgd() const
{
    return currentIgd_;
}

void
UPnPContext::enforceAvailableMappingsLimits()
{
    // If there is no valid IGD, do nothing.
    if (!isReady())
        return;

    for (auto type : {PortType::TCP, PortType::UDP}) {
        int pendingCount = 0;
        int inProgressCount = 0;
        int openCount = 0;
        int unavailableCount = 0;
        {
            std::lock_guard lock(mappingMutex_);
            const auto& mappingList = getMappingList(type);
            for (const auto& [_, mapping] : mappingList) {
                if (!mapping->isAvailable()) {
                    unavailableCount++;
                    continue;
                }
                switch (mapping->getState()) {
                case MappingState::PENDING:
                    pendingCount++;
                    break;
                case MappingState::IN_PROGRESS:
                    inProgressCount++;
                    break;
                case MappingState::OPEN:
                    openCount++;
                    break;
                default:
                    break;
                }
            }
        }
        int availableCount = openCount + pendingCount + inProgressCount;
        if (logger_)
            logger_->debug("Number of {} mappings in the local list: {} available ({} open + {} pending + {} in "
                           "progress), {} in use",
                           Mapping::getTypeStr(type),
                           availableCount,
                           openCount,
                           pendingCount,
                           inProgressCount,
                           unavailableCount);

        int minAvailableMappings = getMinAvailableMappings(type);
        if (minAvailableMappings > availableCount) {
            provisionNewMappings(type, minAvailableMappings - availableCount);
            continue;
        }

        int maxAvailableMappings = getMaxAvailableMappings(type);
        if (openCount > maxAvailableMappings) {
            deleteUnneededMappings(type, openCount - maxAvailableMappings);
        }
    }
}

void
UPnPContext::renewMappings()
{
    if (!started_)
        return;

    const auto& igd = getCurrentIgd();
    if (!igd) {
        if (logger_)
            logger_->debug("Unable to renew mappings: No valid IGD available");
        return;
    }

    auto now = sys_clock::now();
    auto nextRenewalTime = sys_clock::time_point::max();

    std::vector<Mapping::sharedPtr_t> toRenew;
    int toRenewLaterCount = 0;

    for (auto type : {PortType::TCP, PortType::UDP}) {
        std::lock_guard lock(mappingMutex_);
        const auto& mappingList = getMappingList(type);
        for (const auto& [_, map] : mappingList) {
            if (not map->isValid())
                continue;
            if (map->getState() != MappingState::OPEN)
                continue;

            auto mapRenewalTime = map->getRenewalTime();
            if (now >= mapRenewalTime) {
                toRenew.emplace_back(map);
            } else if (mapRenewalTime < sys_clock::time_point::max()) {
                toRenewLaterCount++;
                if (mapRenewalTime < nextRenewalTime)
                    nextRenewalTime = map->getRenewalTime();
            }
        }
    }

    if (!toRenew.empty()) {
        if (logger_)
            logger_->debug("Sending renewal requests for {} mapping(s)", toRenew.size());
    }
    for (const auto& map : toRenew) {
        const auto& protocol = protocolList_.at(map->getIgd()->getProtocol());
        protocol->requestMappingRenew(*map);
    }
    if (toRenewLaterCount > 0) {
        nextRenewalTime += MAPPING_RENEWAL_THROTTLING_DELAY;
        if (logger_)
            logger_->debug("{} mapping(s) didn't need to be renewed (next renewal scheduled for {:%Y-%m-%d %H:%M:%S})",
                           toRenewLaterCount,
                           nextRenewalTime);
        mappingRenewalTimer_.expires_at(nextRenewalTime);
        mappingRenewalTimer_.async_wait([this](asio::error_code const& ec) {
            if (ec != asio::error::operation_aborted)
                renewMappings();
        });
    }
}

void
UPnPContext::scheduleMappingsRenewal()
{
    // Debounce the scheduling function so that it doesn't get called multiple
    // times when several mappings are added or renewed in rapid succession.
    renewalSchedulingTimer_.expires_after(std::chrono::milliseconds(500));
    renewalSchedulingTimer_.async_wait([this](asio::error_code const& ec) {
        if (ec != asio::error::operation_aborted)
            _scheduleMappingsRenewal();
    });
}

void
UPnPContext::_scheduleMappingsRenewal()
{
    if (!started_)
        return;

    sys_clock::time_point nextRenewalTime = sys_clock::time_point::max();
    for (auto type : {PortType::TCP, PortType::UDP}) {
        std::lock_guard lock(mappingMutex_);
        const auto& mappingList = getMappingList(type);
        for (const auto& [_, map] : mappingList) {
            if (map->getState() == MappingState::OPEN && map->getRenewalTime() < nextRenewalTime)
                nextRenewalTime = map->getRenewalTime();
        }
    }
    if (nextRenewalTime == sys_clock::time_point::max())
        return;

    // Add a small delay so that we don't have to call renewMappings multiple
    // times in a row (and iterate over the whole list of mappings each time)
    // when multiple mappings have almost the same renewal time.
    nextRenewalTime += MAPPING_RENEWAL_THROTTLING_DELAY;
    if (nextRenewalTime == mappingRenewalTimer_.expiry())
        return;

    if (logger_)
        logger_->debug("Scheduling next port mapping renewal for {:%Y-%m-%d %H:%M:%S}", nextRenewalTime);
    mappingRenewalTimer_.expires_at(nextRenewalTime);
    mappingRenewalTimer_.async_wait([this](asio::error_code const& ec) {
        if (ec != asio::error::operation_aborted)
            renewMappings();
    });
}

void
UPnPContext::syncLocalMappingListWithIgd()
{
    std::lock_guard lock(syncMutex_);
    if (syncRequested_)
        return;

    syncRequested_ = true;
    syncTimer_.expires_after(std::chrono::minutes(5));
    syncTimer_.async_wait([this](asio::error_code const& ec) {
        if (ec != asio::error::operation_aborted)
            _syncLocalMappingListWithIgd();
    });
}

static sys_clock::time_point
_getRenewalTimeFromExpiry(sys_clock::time_point expiryTime)
{
    if (expiryTime == sys_clock::time_point::max())
        return expiryTime;

    auto now = sys_clock::now();
    // Ensure the expiryTime isn't in the past or overstated by the router's UPnP implementation
    if (expiryTime <= now + MAPPING_EXPIRY_MINIMAL_LIFETIME)
        return now;

    return now + (expiryTime - (now + MAPPING_EXPIRY_MINIMAL_LIFETIME)) / 2;
}

std::size_t
UPnPContext::importMappingsFromIgd(std::map<Mapping::key_t, Mapping>& remoteMapList,
                                   unsigned importBudget[2],
                                   const std::string& hostAddress)
{
    std::size_t importedMappings = 0;

    if (remoteMapList.empty()) {
        return importedMappings;
    }

    std::lock_guard lock(mappingMutex_);
    for (auto it = remoteMapList.begin(); it != remoteMapList.end();) {
        std::size_t const typeIndex = static_cast<std::size_t>(it->second.getType());
        if (importBudget[typeIndex] == 0) {
            ++it;
            continue;
        }

        auto importedMap = std::make_shared<Mapping>(it->second);

        if (importedMap->getExpiryTime() <= sys_clock::now() + std::chrono::minutes(1)) {
            if (logger_) {
                logger_->debug("Mapping {} is expiring soon, skipping import", importedMap->toString());
            }
            ++it;
            continue;
        }

        if (logger_) {
            logger_->debug("Importing mapping {} from the IGD", importedMap->toString());
        }

        importedMap->setInternalAddress(hostAddress);
        importedMap->setRenewalTime(_getRenewalTimeFromExpiry(importedMap->getExpiryTime()));
        importedMap->setState(MappingState::OPEN);
        importedMap->setAvailable(true);

        auto& mappingList = getMappingList(importedMap->getType());
        auto [_, inserted] = mappingList.emplace(importedMap->getMapKey(), importedMap);
        if (inserted) {
            importedMappings++;
            importBudget[typeIndex]--;
            it = remoteMapList.erase(it);
        } else {
            ++it;
        }
    }

    return importedMappings;
}

void
UPnPContext::_syncLocalMappingListWithIgd()
{
    {
        std::lock_guard lock(syncMutex_);
        syncRequested_ = false;
    }
    const auto& igd = getCurrentIgd();
    if (!started_ || !igd || igd->getProtocol() != NatProtocolType::PUPNP) {
        return;
    }
    auto pupnp = protocolList_.at(NatProtocolType::PUPNP);
    if (!pupnp->isReady())
        return;

    if (logger_)
        logger_->debug("Synchronizing local mapping list with IGD [{}]", igd->toString());
    auto remoteMapList = pupnp->getMappingsListByDescr(igd, mappingLabel_);
    auto hostAddress = pupnp->getHostAddress().toString();
    bool requestsInProgress = false;
    // Use a temporary list to avoid processing mappings while holding the lock.
    std::list<Mapping::sharedPtr_t> failedMappings;
    unsigned availableMappings[2] {0, 0};
    unsigned importBudget[2] {0, 0};

    for (auto type : {PortType::TCP, PortType::UDP}) {
        std::lock_guard lock(mappingMutex_);
        std::size_t const typeIndex = static_cast<std::size_t>(type);
        for (auto& [_, map] : getMappingList(type)) {
            if (map->getProtocol() != NatProtocolType::PUPNP) {
                continue;
            }
            switch (map->getState()) {
            case MappingState::PENDING:
                [[fallthrough]];
            case MappingState::IN_PROGRESS:
                requestsInProgress = true;
                if (map->isAvailable()) {
                    availableMappings[typeIndex]++;
                }
                break;
            case MappingState::OPEN: {
                auto it = remoteMapList.find(map->getMapKey());
                if (it == remoteMapList.end()) {
                    if (logger_)
                        logger_->warn("Mapping {} (IGD {}) marked as \"OPEN\" but not found in the "
                                      "remote list. Setting state to \"FAILED\".",
                                      map->toString(),
                                      igd->toString());
                    failedMappings.emplace_back(map);
                } else {
                    auto oldExpiryTime = map->getExpiryTime();
                    auto newExpiryTime = it->second.getExpiryTime();
                    // The value of newExpiryTime is based on the mapping's "lease duration" that we got from
                    // the IGD, which is supposed to be (according to the UPnP specification) the number of
                    // seconds remaining before the mapping expires. In practice, the duration values returned
                    // by some routers are only precise to the hour (i.e. they're always multiples of 3600). This
                    // means that newExpiryTime can exceed the real expiry time by up to an hour in the worst case.
                    // In order to avoid accidentally scheduling a mapping's renewal too late, we only allow ourselves to
                    // push back its renewal time if newExpiryTime is bigger than oldExpiryTime by a sufficient margin.
                    if (newExpiryTime < oldExpiryTime
                        || newExpiryTime > oldExpiryTime + std::chrono::seconds(2 * 3600)) {
                        auto newRenewalTime = map->getRenewalTime() + (newExpiryTime - oldExpiryTime) / 2;
                        map->setRenewalTime(newRenewalTime);
                        map->setExpiryTime(newExpiryTime);
                    }
                    if (map->isAvailable()) {
                        availableMappings[typeIndex]++;
                    }
                    remoteMapList.erase(it);
                }
                break;
            }
            default:
                break;
            }
        }
    }

    // Calculate the import budget for each type of mapping based on the number of available mappings we already have
    // and the maximum number of available mappings we want to maintain.
    for (auto type : {PortType::TCP, PortType::UDP}) {
        std::size_t const typeIndex = static_cast<std::size_t>(type);
        auto const maxAvailable = getMaxAvailableMappings(type);
        importBudget[typeIndex] = (availableMappings[typeIndex] >= maxAvailable)
                                      ? 0
                                      : maxAvailable - availableMappings[typeIndex];
    }

    auto importedMappings = importMappingsFromIgd(remoteMapList, importBudget, hostAddress);

    if (importedMappings > 0 && logger_) {
        logger_->debug("Imported {} reusable PUPnP mapping(s) from the IGD", importedMappings);
    }

    scheduleMappingsRenewal();

    for (auto const& map : failedMappings) {
        updateMappingState(map, MappingState::FAILED);
    }
    if (!failedMappings.empty())
        enforceAvailableMappingsLimits();

    if (requestsInProgress) {
        // It's unlikely that there will be requests in progress when this function is
        // called, but if there are, that suggests that we are dealing with a slow
        // router, so we return early instead of sending additional deletion requests
        // (which aren't essential and could end up "competing" with higher-priority
        // creation/renewal requests).
        return;
    }
    // Use a temporary list to avoid processing mappings while holding the lock.
    std::list<Mapping> toRemoveFromIgd;
    {
        std::lock_guard lock(mappingMutex_);

        for (auto const& [_, map] : remoteMapList) {
            const auto& mappingList = getMappingList(map.getType());
            auto it = mappingList.find(map.getMapKey());
            if (it == mappingList.end()) {
                // Not present, request mapping remove.
                toRemoveFromIgd.emplace_back(std::move(map));

                // Make only few remove requests at once.
                if (toRemoveFromIgd.size() >= MAX_REQUEST_REMOVE_COUNT) {
                    // Set for another sync
                    syncLocalMappingListWithIgd();
                    break;
                }
            }
        }
    }

    for (const auto& map : toRemoveFromIgd) {
        pupnp->requestMappingRemove(map);
    }
}

void
UPnPContext::pruneMappingsWithInvalidIgds(const std::shared_ptr<IGD>& igd)
{
    // Use temporary list to avoid holding the lock while
    // processing the mapping list.
    std::list<Mapping::sharedPtr_t> toRemoveList;
    {
        std::lock_guard lock(mappingMutex_);

        PortType types[2] {PortType::TCP, PortType::UDP};
        for (auto& type : types) {
            const auto& mappingList = getMappingList(type);
            for (auto const& [_, map] : mappingList) {
                if (map->getIgd() == igd)
                    toRemoveList.emplace_back(map);
            }
        }
    }

    for (auto const& map : toRemoveList) {
        if (logger_)
            logger_->debug("Remove mapping {} (has an invalid IGD {} [{}])",
                           map->toString(),
                           igd->toString(),
                           igd->getProtocolName());
        updateMappingState(map, MappingState::FAILED);
    }
}

void
UPnPContext::processPendingRequests()
{
    // This list holds the mappings to be requested. This is
    // needed to avoid performing the requests while holding
    // the lock.
    std::list<Mapping::sharedPtr_t> requestsList;

    // Populate the list of requests to perform.
    {
        std::lock_guard lock(mappingMutex_);
        PortType typeArray[2] {PortType::TCP, PortType::UDP};

        for (auto type : typeArray) {
            const auto& mappingList = getMappingList(type);
            for (const auto& [_, map] : mappingList) {
                if (map->getState() == MappingState::PENDING) {
                    if (logger_)
                        logger_->debug("Attempting to send a request for pending mapping {}", map->toString());
                    requestsList.emplace_back(map);
                }
            }
        }
    }

    // Process the pending requests.
    for (auto const& map : requestsList) {
        requestMapping(map);
    }
}

void
UPnPContext::onIgdUpdated(const std::shared_ptr<IGD>& igd, UpnpIgdEvent event)
{
    if (!stateCtx->get_executor().running_in_this_thread()) {
        asio::post(*stateCtx, [this, igd, event = std::move(event)] { onIgdUpdated(igd, event); });
        return;
    }
    assert(igd);

    char const* IgdState = event == UpnpIgdEvent::ADDED     ? "ADDED"
                           : event == UpnpIgdEvent::REMOVED ? "REMOVED"
                                                            : "INVALID";

    auto const& igdLocalAddr = igd->getLocalIp();
    auto protocolName = igd->getProtocolName();

    if (logger_)
        logger_->debug("New event for IGD [{} {}] [{}]: [{}]", igd->getUID(), igd->toString(), protocolName, IgdState);

    if (not igdLocalAddr) {
        if (logger_)
            logger_->warn("[{}] IGD [{} {}] has an invalid local address, ignoring",
                          protocolName,
                          igd->getUID(),
                          igd->toString());
        return;
    }

    if (not igd->getPublicIp()) {
        if (logger_)
            logger_->warn("[{}] IGD [{} {}] has an invalid public address, ignoring",
                          protocolName,
                          igd->getUID(),
                          igd->toString());
        return;
    }

    {
        std::lock_guard lock(publicAddressMutex_);
        if (knownPublicAddress_ and igd->getPublicIp() != knownPublicAddress_) {
            if (logger_)
                logger_->warn("[{}] IGD external address [{}] does not match known public address [{}]. "
                              "The mapped addresses might not be reachable",
                              protocolName,
                              igd->getPublicIp().toString(),
                              knownPublicAddress_.toString());
        }
    }

    if (event == UpnpIgdEvent::REMOVED or event == UpnpIgdEvent::INVALID_STATE) {
        if (logger_)
            logger_->warn("State of IGD [{} {}] [{}] changed to [{}]. Pruning the mapping list",
                          igd->getUID(),
                          igd->toString(),
                          protocolName,
                          IgdState);

        pruneMappingsWithInvalidIgds(igd);
    }

    updateCurrentIgd();
    if (isReady()) {
        // Sync before the next steps to ensure the available mappings are up-to-date
        if (igd->getProtocol() == NatProtocolType::PUPNP) {
            _syncLocalMappingListWithIgd();
        }
        processPendingRequests();
        enforceAvailableMappingsLimits();
    }
}

void
UPnPContext::onMappingAdded(const std::shared_ptr<IGD>& igd, const Mapping& mapRes)
{
    if (!stateCtx->get_executor().running_in_this_thread()) {
        asio::post(*stateCtx, [this, igd, mapRes] { onMappingAdded(igd, mapRes); });
        return;
    }

    // Check if we have a pending request for this response.
    auto map = getMappingWithKey(mapRes.getMapKey());
    if (not map) {
        // We may receive a response for a canceled request. Just ignore it.
        if (logger_)
            logger_->debug("Response for mapping {} [IGD {}] [{}] does not have a local match",
                           mapRes.toString(),
                           igd->toString(),
                           mapRes.getProtocolName());
        return;
    }

    // The mapping request is new and successful. Update.
    map->setAddedInfo(mapRes);
    // Update the state and report to the owner.
    updateMappingState(map, MappingState::OPEN);
    scheduleMappingsRenewal();

    if (logger_)
        logger_->debug("Mapping {} (on IGD {} [{}]) successfully performed",
                       map->toString(),
                       igd->toString(),
                       map->getProtocolName());

    // Call setValid() to reset the errors counter. We need
    // to reset the counter on each successful response.
    igd->setValid(true);
    if (igd->getProtocol() == NatProtocolType::PUPNP)
        syncLocalMappingListWithIgd();
}

void
UPnPContext::onMappingRenewed(const std::shared_ptr<IGD>& igd, const Mapping& map)
{
    if (!stateCtx->get_executor().running_in_this_thread()) {
        asio::post(*stateCtx, [this, igd, map] { onMappingRenewed(igd, map); });
        return;
    }

    auto mapPtr = getMappingWithKey(map.getMapKey());

    if (not mapPtr) {
        if (logger_)
            logger_->warn("Renewed mapping {} from IGD  {} [{}] does not have a match in local list",
                          map.toString(),
                          igd->toString(),
                          map.getProtocolName());
        return;
    }
    if (!mapPtr->isValid() || mapPtr->getState() != MappingState::OPEN) {
        if (logger_)
            logger_->warn("Renewed mapping {} from IGD {} [{}] is in an unexpected state",
                          mapPtr->toString(),
                          igd->toString(),
                          mapPtr->getProtocolName());
        return;
    }

    mapPtr->setRenewalTime(map.getRenewalTime());
    mapPtr->setExpiryTime(map.getExpiryTime());
    scheduleMappingsRenewal();
    if (igd->getProtocol() == NatProtocolType::PUPNP)
        syncLocalMappingListWithIgd();
}

void
UPnPContext::requestRemoveMapping(const Mapping::sharedPtr_t& map)
{
    if (not map or not map->isValid()) {
        // Silently ignore if the mapping is invalid
        return;
    }
    auto protocol = protocolList_.at(map->getIgd()->getProtocol());
    protocol->requestMappingRemove(*map);
}

void
UPnPContext::onMappingRemoved(const std::shared_ptr<IGD>& igd, const Mapping& mapRes)
{
    if (!stateCtx->get_executor().running_in_this_thread()) {
        asio::post(*stateCtx, [this, igd, mapRes] { onMappingRemoved(igd, mapRes); });
        return;
    }

    if (not mapRes.isValid())
        return;

    auto map = getMappingWithKey(mapRes.getMapKey());
    // Notify the listener.
    Mapping::notify(map);
}

void
UPnPContext::onIgdDiscoveryStarted()
{
    if (!stateCtx->get_executor().running_in_this_thread()) {
        asio::post(*stateCtx, [this] { onIgdDiscoveryStarted(); });
        return;
    }
    std::lock_guard lock(igdDiscoveryMutex_);
    igdDiscoveryInProgress_ = true;
    if (logger_)
        logger_->debug("IGD Discovery started");
    igdDiscoveryTimer_.expires_after(igdDiscoveryTimeout_);
    igdDiscoveryTimer_.async_wait([this](const asio::error_code& ec) {
        if (ec != asio::error::operation_aborted && igdDiscoveryInProgress_) {
            _endIgdDiscovery();
        }
    });
}

void
UPnPContext::_endIgdDiscovery()
{
    std::lock_guard lockDiscovery_(igdDiscoveryMutex_);
    igdDiscoveryInProgress_ = false;
    if (logger_)
        logger_->debug("IGD Discovery ended");
    if (isReady()) {
        return;
    }

    // Use a temporary list to avoid holding the lock while processing the mapping list.
    // This is necessary because updateMappingState calls a user-defined callback, which
    // in turn could end up calling a function (such as reserveMapping) that would also
    // attempt to lock mappingMutex_.
    std::list<Mapping::sharedPtr_t> toRemoveList;
    {
        std::lock_guard lock(mappingMutex_);
        PortType types[2] {PortType::TCP, PortType::UDP};
        for (auto type : types) {
            const auto& mappingList = getMappingList(type);
            for (const auto& [_, map] : mappingList) {
                toRemoveList.emplace_back(map);
            }
        }
    }
    // We reached the end of the IGD discovery period without finding a valid IGD,
    // so all mapping requests are considered to have failed.
    for (auto const& map : toRemoveList) {
        updateMappingState(map, MappingState::FAILED);
    }
}

void
UPnPContext::setIgdDiscoveryTimeout(std::chrono::milliseconds timeout)
{
    std::lock_guard lock(igdDiscoveryMutex_);
    igdDiscoveryTimeout_ = timeout;
}

Mapping::sharedPtr_t
UPnPContext::registerMapping(Mapping& map, bool available)
{
    Mapping::sharedPtr_t mapPtr;

    map.setLabel(mappingLabel_);
    if (map.getExternalPort() == 0) {
        auto port = getAvailablePortNumber(map.getType());
        if (port == 0) {
            if (logger_)
                logger_->error("Unable to register mapping: No available port number");
            return mapPtr;
        }
        map.setExternalPort(port);
        map.setInternalPort(port);
    }

    // Newly added mapping must be in pending state by default.
    map.setState(MappingState::PENDING);

    {
        std::lock_guard lock(mappingMutex_);
        auto& mappingList = getMappingList(map.getType());

        auto ret = mappingList.emplace(map.getMapKey(), std::make_shared<Mapping>(map));
        if (not ret.second) {
            if (logger_)
                logger_->warn("Mapping request for {} already added!", map.toString());
            return {};
        }
        mapPtr = ret.first->second;
        // It's important to set the mapping's availability while mappingMutex_ is locked,
        // otherwise a call to reserveMapping from a different thread could try to use the
        // mapping we just added to the mapping list even if `available` is false.
        mapPtr->setAvailable(available);
        assert(mapPtr);
    }

    if (not isReady()) {
        // There is no valid IGD available
        std::lock_guard lock(igdDiscoveryMutex_);
        // IGD discovery is in progress, the mapping request will be made once an IGD becomes available
        if (igdDiscoveryInProgress_) {
            if (logger_)
                logger_->debug("Mapping {} will be requested when an IGD becomes available", map.toString());
        } else {
            // it's not in the IGD discovery phase, the mapping request will fail
            if (logger_)
                logger_->warn("Request for mapping {} failed, no IGD available", map.toString());
            updateMappingState(mapPtr, MappingState::FAILED);
            // The call to `updateMappingState` above will cause the mapping to be
            // removed from the mapping list, so we return a null pointer.
            return {};
        }
    } else {
        // There is a valid IGD available, request the mapping.
        requestMapping(mapPtr);
    }
    return mapPtr;
}

void
UPnPContext::unregisterMapping(const Mapping::sharedPtr_t& map)
{
    if (not map) {
        return;
    }

    std::lock_guard lock(mappingMutex_);
    auto& mappingList = getMappingList(map->getType());

    if (mappingList.erase(map->getMapKey()) == 1) {
        if (logger_)
            logger_->debug("Unregistered mapping {}", map->toString());
    } else {
        // The mapping may already be un-registered. Just ignore it.
        if (logger_)
            logger_->debug("Unable to unregister mapping {} [{}] since it doesn't have a local match",
                           map->toString(),
                           map->getProtocolName());
    }
}

std::map<Mapping::key_t, Mapping::sharedPtr_t>&
UPnPContext::getMappingList(PortType type)
{
    unsigned typeIdx = type == PortType::TCP ? 0 : 1;
    return mappingList_[typeIdx];
}

Mapping::sharedPtr_t
UPnPContext::getMappingWithKey(Mapping::key_t key)
{
    std::lock_guard lock(mappingMutex_);
    auto const& mappingList = getMappingList(Mapping::getTypeFromMapKey(key));
    auto it = mappingList.find(key);
    if (it == mappingList.end())
        return nullptr;
    return it->second;
}

void
UPnPContext::onMappingRequestFailed(const Mapping& mapRes)
{
    if (!stateCtx->get_executor().running_in_this_thread()) {
        asio::post(*stateCtx, [this, mapRes] { onMappingRequestFailed(mapRes); });
        return;
    }

    auto igd = mapRes.getIgd();
    auto const& map = getMappingWithKey(mapRes.getMapKey());
    if (not map) {
        // We may receive a response for a removed request. Just ignore it.
        if (logger_)
            logger_->debug("Ignoring failed request for mapping {} [IGD {}] since it doesn't have a local match",
                           mapRes.toString(),
                           igd->toString());
        return;
    }

    updateMappingState(map, MappingState::FAILED);

    if (logger_)
        logger_->warn("Request for mapping {} on IGD {} failed", map->toString(), igd->toString());

    enforceAvailableMappingsLimits();
}

void
UPnPContext::updateMappingState(const Mapping::sharedPtr_t& map, MappingState newState, bool notify)
{
    assert(map);

    // Ignore if the state did not change.
    if (newState == map->getState()) {
        return;
    }

    // Update the state.
    map->setState(newState);

    // Notify the listener if set.
    if (notify)
        Mapping::notify(map);

    if (newState == MappingState::FAILED)
        handleFailedMapping(map);
}

void
UPnPContext::handleFailedMapping(const Mapping::sharedPtr_t& map)
{
    if (!map->getAutoUpdate()) {
        // If the mapping is not set to auto-update, it will be unregistered.
        unregisterMapping(map);
        return;
    }

    if (isReady()) {
        // We have a valid IGD, but the mapping request
        // failed, so try again with a new port number.
        Mapping newMapping(map->getType());
        newMapping.enableAutoUpdate(true);
        newMapping.setNotifyCallback(map->getNotifyCallback());

        // Remove the old (failed) mapping from the local list
        map->setNotifyCallback(nullptr);
        unregisterMapping(map);

        if (logger_)
            logger_->debug("Mapping {} had auto-update enabled, a new mapping will be requested", map->toString());
        reserveMapping(newMapping);
    } else {
        // If there is no valid IGD, the mapping is marked as pending
        // and will be requested when an IGD becomes available.
        updateMappingState(map, MappingState::PENDING, false);
        if (logger_)
            logger_->debug("Mapping {} will be requested when an IGD becomes available", map->toString());
    }
}

} // namespace upnp
} // namespace dhtnet
