// This file Copyright © Mnemosyne LLC.
// It may be used under GPLv2 (SPDX: GPL-2.0-only), GPLv3 (SPDX: GPL-3.0-only),
// or any future license endorsed by Mnemosyne LLC.
// License text can be found in the licenses/ folder.

#include <algorithm>
#include <array>
#include <chrono> // operator""ms
#include <cstddef> // size_t
#include <cstdint>
#include <ctime>
#include <deque>
#include <iterator>
#include <map>
#include <memory>
#include <numeric>
#include <optional>
#include <ranges>
#include <set>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

#include <fmt/format.h>

#define LIBTRANSMISSION_ANNOUNCER_MODULE

#include "libtransmission/transmission.h"

#include "libtransmission/announce-list.h"
#include "libtransmission/announcer-common.h"
#include "libtransmission/announcer.h"
#include "libtransmission/crypto-utils.h" /* tr_rand_int() */
#include "libtransmission/interned-string.h" // tr_interned_string
#include "libtransmission/log.h"
#include "libtransmission/session.h"
#include "libtransmission/timer.h"
#include "libtransmission/torrent.h"
#include "libtransmission/tr-assert.h"
#include "libtransmission/tr-macros.h" // tr_sha1_digest_t, TR_C...
#include "libtransmission/utils.h"
#include "libtransmission/web-utils.h"

using namespace std::literals;

#define tr_logAddErrorTier(tier, msg) tr_logAddError(msg, (tier)->buildLogName())
#define tr_logAddWarnTier(tier, msg) tr_logAddWarn(msg, (tier)->buildLogName())
#define tr_logAddDebugTier(tier, msg) tr_logAddDebug(msg, (tier)->buildLogName())
#define tr_logAddTraceTier(tier, msg) tr_logAddTrace(msg, (tier)->buildLogName())

namespace
{
/* unless the tracker says otherwise, rescrape this frequently */
auto constexpr DefaultScrapeIntervalSec = time_t{ 60 * 30 };

/* the value of the 'numwant' argument passed in tracker requests. */
auto constexpr Numwant = 80;

/* how often to announce & scrape */
auto constexpr MaxAnnouncesPerUpkeep = 20;
auto constexpr MaxScrapesPerUpkeep = 20;

/* how many infohashes to remove when we get a scrape-too-long error */
auto constexpr TrMultiscrapeStep = 5U;

struct StopsCompare
{
    [[nodiscard]] static constexpr auto compare(tr_announce_request const& one, tr_announce_request const& two) noexcept // <=>
    {
        // primary key: volume of data transferred
        auto const ax = one.up + one.down;
        auto const bx = two.up + two.down;
        if (auto const val = tr_compare_3way(ax, bx); val != 0)
        {
            return val;
        }

        // secondary key: the torrent's info_hash
        for (size_t i = 0; i < sizeof(tr_sha1_digest_t); ++i)
        {
            if (auto const val = tr_compare_3way(one.info_hash[i], two.info_hash[i]); val != 0)
            {
                return val;
            }
        }

        // tertiary key: the tracker's announce url
        return tr_compare_3way(one.announce_url, two.announce_url);
    }

    [[nodiscard]] constexpr auto operator()(tr_announce_request const& one, tr_announce_request const& two) const noexcept
    {
        return compare(one, two) < 0;
    }
};

} // namespace

// ---

struct tr_scrape_info
{
    size_t multiscrape_max;

    tr_interned_string scrape_url;

    constexpr tr_scrape_info(tr_interned_string scrape_url_in, size_t const multiscrape_max_in)
        : multiscrape_max{ multiscrape_max_in }
        , scrape_url{ scrape_url_in }
    {
    }
};

/**
 * "global" (per-tr_session) fields
 */
class tr_announcer_impl final : public tr_announcer
{
public:
    explicit tr_announcer_impl(tr_session* session_in, tr_announcer_udp& announcer_udp)
        : session{ session_in }
        , announcer_udp_{ announcer_udp }
        , upkeep_timer_{ session_in->timerMaker().create() }
    {
        upkeep_timer_->set_callback([this]() { this->upkeep(); });
        upkeep_timer_->start_repeating(UpkeepInterval);
    }

    ~tr_announcer_impl() override
    {
        flushCloseMessages();
    }

    tr_announcer_impl(tr_announcer_impl&&) = delete;
    tr_announcer_impl(tr_announcer_impl const&) = delete;
    tr_announcer_impl& operator=(tr_announcer_impl&&) = delete;
    tr_announcer_impl& operator=(tr_announcer_impl const&) = delete;

    tr_torrent_announcer* addTorrent(tr_torrent* tor, tr_tracker_callback callback) override;
    void startTorrent(tr_torrent* tor) override;
    void stopTorrent(tr_torrent* tor) override;
    void resetTorrent(tr_torrent* tor) override;
    void removeTorrent(tr_torrent* tor) override;

    void startShutdown() override
    {
        is_shutting_down_ = true;
        flushCloseMessages();
    }

    void upkeep() override;

    void onAnnounceDone(
        size_t tier_id,
        tr_announce_event event,
        bool is_running_on_success,
        tr_announce_response const& response);
    void onScrapeDone(tr_scrape_response const& response);

    [[nodiscard]] tr_scrape_info* scrape_info(tr_interned_string url)
    {
        if (std::empty(url))
        {
            return nullptr;
        }

        auto const [it, is_new] = scrape_info_.try_emplace(url, url, TrMultiscrapeMax);
        return &it->second;
    }

    void scrape(tr_scrape_request const& request, tr_scrape_response_func on_response)
    {
        TR_ASSERT(!is_shutting_down_);

        if (auto const scrape_sv = request.scrape_url.sv();
            tr_strv_starts_with(scrape_sv, "http://"sv) || tr_strv_starts_with(scrape_sv, "https://"sv))
        {
            tr_tracker_http_scrape(session, request, std::move(on_response));
        }
        else if (tr_strv_starts_with(scrape_sv, "udp://"sv))
        {
            announcer_udp_.scrape(request, std::move(on_response));
        }
        else
        {
            tr_logAddError(fmt::format(fmt::runtime(_("Unsupported URL: '{url}'")), fmt::arg("url", scrape_sv)));
        }
    }

    void announce(tr_announce_request const& request, tr_announce_response_func on_response)
    {
        TR_ASSERT(!is_shutting_down_ || request.event == TR_ANNOUNCE_EVENT_STOPPED);

        if (auto const announce_sv = request.announce_url.sv();
            tr_strv_starts_with(announce_sv, "http://"sv) || tr_strv_starts_with(announce_sv, "https://"sv))
        {
            tr_tracker_http_announce(session, request, std::move(on_response));
        }
        else if (tr_strv_starts_with(announce_sv, "udp://"sv))
        {
            announcer_udp_.announce(request, std::move(on_response));
        }
        else
        {
            tr_logAddWarn(fmt::format(fmt::runtime(_("Unsupported URL: '{url}'")), fmt::arg("url", announce_sv)));
        }
    }

    tr_session* const session;

private:
    void flushCloseMessages()
    {
        for (auto& stop : stops_)
        {
            announce(stop, [](tr_announce_response const& /*response*/) {});
        }

        stops_.clear();
    }

    static auto constexpr UpkeepInterval = 500ms;

    tr_announcer_udp& announcer_udp_;

    std::map<tr_interned_string, tr_scrape_info> scrape_info_;

    std::unique_ptr<tr::Timer> const upkeep_timer_;

    std::set<tr_announce_request, StopsCompare> stops_;

    bool is_shutting_down_ = false;
};

std::unique_ptr<tr_announcer> tr_announcer::create(tr_session* session, tr_announcer_udp& announcer_udp)
{
    TR_ASSERT(session != nullptr);
    return std::make_unique<tr_announcer_impl>(session, announcer_udp);
}

// ---

/* a row in tr_tier's list of trackers */
struct tr_tracker
{
    explicit tr_tracker(tr_announcer_impl* announcer, tr_announce_list::tracker_info const& info)
        : announce_url{ info.announce }
        , announce_parsed{ info.announce_parsed }
        , scrape_info{ std::empty(info.scrape) ? nullptr : announcer->scrape_info(info.scrape) }
        , id{ info.id }
    {
    }

    [[nodiscard]] time_t getRetryInterval() const
    {
        switch (consecutive_failures)
        {
        case 0U:
            return 0U;

        case 1U:
            return 20U;

        case 2U:
            return tr_rand_int(60U) + (60U * 5U);

        case 3U:
            return tr_rand_int(60U) + (60U * 15U);

        case 4U:
            return tr_rand_int(60U) + (60U * 30U);

        case 5U:
            return tr_rand_int(60U) + (60U * 60U);

        default:
            return tr_rand_int(60U) + (60U * 120U);
        }
    }

    [[nodiscard]] constexpr auto seeder_count() const noexcept
    {
        return seeder_count_;
    }

    constexpr bool set_seeder_count(std::optional<int64_t> seeder_count_in) noexcept
    {
        if (seeder_count_in >= 0)
        {
            seeder_count_ = seeder_count_in;
            return true;
        }
        return false;
    }

    [[nodiscard]] constexpr auto leecher_count() const noexcept
    {
        return leecher_count_;
    }

    constexpr bool set_leecher_count(std::optional<int64_t> leecher_count_in) noexcept
    {
        if (leecher_count_in >= 0)
        {
            leecher_count_ = leecher_count_in;
            return true;
        }
        return false;
    }

    [[nodiscard]] constexpr auto download_count() const noexcept
    {
        return download_count_;
    }

    constexpr bool set_download_count(std::optional<int64_t> download_count_in) noexcept
    {
        if (download_count_in >= 0)
        {
            download_count_ = download_count_in;
            return true;
        }
        return false;
    }

    [[nodiscard]] constexpr auto downloader_count() const noexcept
    {
        return downloader_count_;
    }

    constexpr bool set_downloader_count(std::optional<int64_t> downloader_count_in) noexcept
    {
        if (downloader_count_in >= 0)
        {
            downloader_count_ = downloader_count_in;
            return true;
        }
        return false;
    }

    tr_interned_string const announce_url;
    tr_url_parsed_t const announce_parsed;
    tr_scrape_info* const scrape_info;

    std::string tracker_id;

    size_t consecutive_failures = 0;

    tr_tracker_id_t const id;

private:
    std::optional<int64_t> seeder_count_;
    std::optional<int64_t> leecher_count_;
    std::optional<int64_t> download_count_;
    std::optional<int64_t> downloader_count_;
};

// ---

/** @brief A group of trackers in a single tier, as per the multitracker spec */
struct tr_tier
{
    tr_tier(tr_announcer_impl* announcer, tr_torrent* tor_in, std::vector<tr_announce_list::tracker_info const*> const& infos)
        : tor{ tor_in }
    {
        trackers.reserve(std::size(infos));
        for (auto const* info : infos)
        {
            trackers.emplace_back(announcer, *info);
        }
        useNextTracker();
        scrapeSoon();
    }

    [[nodiscard]] tr_tracker* currentTracker()
    {
        if (!current_tracker_index_)
        {
            return nullptr;
        }

        TR_ASSERT(*current_tracker_index_ < std::size(trackers));
        return &trackers[*current_tracker_index_];
    }

    [[nodiscard]] tr_tracker const* currentTracker() const
    {
        if (!current_tracker_index_)
        {
            return nullptr;
        }

        TR_ASSERT(*current_tracker_index_ < std::size(trackers));
        return &trackers[*current_tracker_index_];
    }

    [[nodiscard]] constexpr bool needsToAnnounce(time_t now) const
    {
        return !isAnnouncing && !isScraping && announceAt != 0 && announceAt <= now && !std::empty(announce_events);
    }

    [[nodiscard]] bool needsToScrape(time_t now) const
    {
        auto const* const tracker = currentTracker();

        return !isScraping && scrapeAt != 0 && scrapeAt <= now && tracker != nullptr && tracker->scrape_info != nullptr;
    }

    [[nodiscard]] auto countDownloaders() const
    {
        auto const* const tracker = currentTracker();

        return tracker == nullptr ? 0 : tracker->downloader_count().value_or(-1) + tracker->leecher_count().value_or(-1);
    }

    tr_tracker* useNextTracker()
    {
        // move our index to the next tracker in the tier
        if (std::empty(trackers))
        {
            current_tracker_index_ = std::nullopt;
        }
        else if (!current_tracker_index_)
        {
            current_tracker_index_ = 0;
        }
        else
        {
            current_tracker_index_ = (*current_tracker_index_ + 1) % std::size(trackers);
        }

        // reset some of the tier's fields
        scrapeIntervalSec = DefaultScrapeIntervalSec;
        announceIntervalSec = DefaultAnnounceIntervalSec;
        announceMinIntervalSec = DefaultAnnounceMinIntervalSec;
        isAnnouncing = false;
        isScraping = false;
        lastAnnounceStartTime = 0;
        lastScrapeStartTime = 0;

        return currentTracker();
    }

    [[nodiscard]] std::optional<size_t> indexOf(tr_interned_string announce_url) const
    {
        for (size_t i = 0, n = std::size(trackers); i < n; ++i)
        {
            if (announce_url == trackers[i].announce_url)
            {
                return i;
            }
        }

        return std::nullopt;
    }

    [[nodiscard]] auto buildLogName() const
    {
        auto const* const tracker = currentTracker();
        return tracker != nullptr ?
            fmt::format("{:s} at {:s}:{:d}", tor->name(), tracker->announce_parsed.host, tracker->announce_parsed.port) :
            fmt::format("{:s} at ?", tor->name());
    }

    [[nodiscard]] bool canManualAnnounce() const
    {
        return this->manualAnnounceAllowedAt <= tr_time();
    }

    void scheduleNextScrape()
    {
        scheduleNextScrape(this->scrapeIntervalSec);
    }

    void scrapeSoon()
    {
        scheduleNextScrape(0);
    }

    void scheduleNextScrape(time_t interval_secs)
    {
        this->scrapeAt = getNextScrapeTime(tor->session, this, interval_secs);
    }

    std::deque<tr_announce_event> announce_events;

    std::string last_announce_str;
    std::string last_scrape_str;

    /* number of up/down/corrupt bytes since the last time we sent an
     * "event=stopped" message that was acknowledged by the tracker */
    std::array<uint64_t, 3> byteCounts = {};

    std::vector<tr_tracker> trackers;

    std::optional<size_t> current_tracker_index_;

    tr_torrent* const tor;

    time_t scrapeAt = 0;
    time_t lastScrapeStartTime = 0;
    time_t lastScrapeTime = 0;

    time_t announceAt = 0;
    time_t manualAnnounceAllowedAt = 0;
    time_t lastAnnounceStartTime = 0;
    time_t lastAnnounceTime = 0;

    size_t const id = next_key++;

    int announce_event_priority = 0;

    time_t scrapeIntervalSec = DefaultScrapeIntervalSec;
    time_t announceIntervalSec = DefaultAnnounceIntervalSec;
    time_t announceMinIntervalSec = DefaultAnnounceMinIntervalSec;

    size_t lastAnnouncePeerCount = 0;

    bool lastScrapeSucceeded = false;
    bool lastScrapeTimedOut = false;

    bool lastAnnounceSucceeded = false;
    bool lastAnnounceTimedOut = false;

    bool isRunning = false;
    bool isAnnouncing = false;
    bool isScraping = false;

private:
    // unless the tracker says otherwise, this is the announce interval
    static auto constexpr DefaultAnnounceIntervalSec = time_t{ 60 * 10 };

    // unless the tracker says otherwise, this is the announce min_interval
    static auto constexpr DefaultAnnounceMinIntervalSec = time_t{ 60 * 2 };

    [[nodiscard]] static time_t getNextScrapeTime(tr_session const* session, tr_tier const* tier, time_t interval_secs)
    {
        // Maybe don't scrape paused torrents
        if (!tier->isRunning && !session->shouldScrapePausedTorrents())
        {
            return 0;
        }

        /* Add the interval, and then increment to the nearest 10th second.
         * The latter step is to increase the odds of several torrents coming
         * due at the same time to improve multiscrape. */
        auto ret = tr_time() + interval_secs;
        while (ret % 10U != 0U)
        {
            ++ret;
        }

        return ret;
    }

    static inline size_t next_key = {};
};

// ---

/**
 * @brief Opaque, per-torrent data structure for tracker announce information
 *
 * this opaque data structure can be found in tr_torrent.tiers
 */
struct tr_torrent_announcer
{
    tr_torrent_announcer(tr_announcer_impl* announcer, tr_torrent* tor)
    {
        // build the trackers
        auto tier_to_infos = std::map<tr_tracker_tier_t, std::vector<tr_announce_list::tracker_info const*>>{};
        auto const announce_list = getAnnounceList(tor);
        for (auto const& info : announce_list)
        {
            tier_to_infos[info.tier].emplace_back(&info);
        }

        for (auto const& [tier_num, infos] : tier_to_infos)
        {
            tiers.emplace_back(announcer, tor, infos);
        }
    }

    tr_tier* getTier(size_t tier_id)
    {
        for (auto& tier : tiers)
        {
            if (tier.id == tier_id)
            {
                return &tier;
            }
        }

        return nullptr;
    }

    tr_tier* getTierFromScrape(tr_interned_string scrape_url)
    {
        for (auto& tier : tiers)
        {
            auto const* const tracker = tier.currentTracker();

            if (tracker != nullptr && tracker->scrape_info != nullptr && tracker->scrape_info->scrape_url == scrape_url)
            {
                return &tier;
            }
        }

        return nullptr;
    }

    [[nodiscard]] bool canManualAnnounce() const
    {
        return std::ranges::any_of(tiers, [](auto const& tier) { return tier.canManualAnnounce(); });
    }

    [[nodiscard]] bool findTracker(
        tr_interned_string announce_url,
        tr_tier const** setme_tier,
        tr_tracker const** setme_tracker) const
    {
        for (auto const& tier : tiers)
        {
            for (auto const& tracker : tier.trackers)
            {
                if (tracker.announce_url == announce_url)
                {
                    *setme_tier = &tier;
                    *setme_tracker = &tracker;
                    return true;
                }
            }
        }

        return false;
    }

    std::vector<tr_tier> tiers;

    tr_tracker_callback callback;

private:
    [[nodiscard]] static tr_announce_list getAnnounceList(tr_torrent const* tor)
    {
        auto announce_list = tor->announce_list();

        // if it's a public torrent, add the default trackers
        if (tor->is_public())
        {
            announce_list.add(tor->session->defaultTrackers());
        }

        return announce_list;
    }
};

// --- PUBLISH

namespace
{
namespace publish_helpers
{
void publishMessage(tr_tier* tier, std::string_view msg, tr_tracker_event::Type type)
{
    if (tier != nullptr && tier->tor != nullptr && tier->tor->torrent_announcer != nullptr &&
        tier->tor->torrent_announcer->callback != nullptr)
    {
        auto* const ta = tier->tor->torrent_announcer;
        auto event = tr_tracker_event{};
        event.type = type;
        event.text = msg;

        if (auto const* const current_tracker = tier->currentTracker(); current_tracker != nullptr)
        {
            event.announce_url = current_tracker->announce_url;
        }

        ta->callback(*tier->tor, &event);
    }
}

void publishErrorClear(tr_tier* tier)
{
    publishMessage(tier, ""sv, tr_tracker_event::Type::ErrorClear);
}

void publishWarning(tr_tier* tier, std::string_view msg)
{
    publishMessage(tier, msg, tr_tracker_event::Type::Warning);
}

void publishError(tr_tier* tier, std::string_view msg)
{
    publishMessage(tier, msg, tr_tracker_event::Type::Error);
}

void publishPeerCounts(
    tr_tier* tier,
    std::optional<int64_t> seeders,
    std::optional<int64_t> leechers,
    std::optional<int64_t> downloaders)
{
    if (tier->tor->torrent_announcer->callback != nullptr)
    {
        auto e = tr_tracker_event{};
        e.type = tr_tracker_event::Type::Counts;
        e.seeders = seeders;
        e.leechers = leechers;
        e.downloaders = downloaders;
        tr_logAddDebugTier(
            tier,
            fmt::format(
                "peer counts: {} seeders, {} leechers, {} downloaders.",
                seeders.value_or(-1),
                leechers.value_or(-1),
                downloaders.value_or(-1)));

        tier->tor->torrent_announcer->callback(*tier->tor, &e);
    }
}

void publishPeersPex(tr_tier* tier, std::vector<tr_pex> const& pex)
{
    if (tier->tor->torrent_announcer->callback != nullptr)
    {
        auto e = tr_tracker_event{};
        e.type = tr_tracker_event::Type::Peers;
        e.pex = pex;
        tr_logAddDebugTier(tier, fmt::format("tracker gave a list of {} peers.", std::size(pex)));

        tier->tor->torrent_announcer->callback(*tier->tor, &e);
    }
}
} // namespace publish_helpers
} // namespace

// ---

tr_torrent_announcer* tr_announcer_impl::addTorrent(tr_torrent* tor, tr_tracker_callback callback)
{
    TR_ASSERT(tr_isTorrent(tor));

    auto* ta = new tr_torrent_announcer{ this, tor };
    ta->callback = std::move(callback);
    return ta;
}

// ---

bool tr_announcerCanManualAnnounce(tr_torrent const* tor)
{
    TR_ASSERT(tr_isTorrent(tor));
    TR_ASSERT(tor->torrent_announcer != nullptr);

    return tor->is_running() && tor->torrent_announcer->canManualAnnounce();
}

time_t tr_announcerNextManualAnnounce(tr_torrent const* tor)
{
    time_t ret = ~(time_t)0;

    for (auto const& tier : tor->torrent_announcer->tiers)
    {
        if (tier.isRunning)
        {
            ret = std::min(ret, tier.manualAnnounceAllowedAt);
        }
    }

    return ret;
}

namespace
{
namespace announce_helpers
{
void tr_logAddTrace_tier_announce_queue(tr_tier const* tier)
{
    if (!tr_logLevelIsActive(TR_LOG_TRACE) || std::empty(tier->announce_events))
    {
        return;
    }

    auto buf = std::string{};
    auto const& events = tier->announce_events;
    buf.reserve(std::size(events) * 20);
    for (size_t i = 0, n = std::size(events); i < n; ++i)
    {
        fmt::format_to(std::back_inserter(buf), "[{:d}:{:s}]", i, tr_announce_event_get_string(events[i]));
    }

    tr_logAddTraceTier(tier, std::move(buf));
}

// higher priorities go to the front of the announce queue
void tier_update_announce_priority(tr_tier* tier)
{
    int priority = -1;

    for (auto const& event : tier->announce_events)
    {
        priority = std::max(priority, int{ event });
    }

    tier->announce_event_priority = priority;
}

void tier_announce_remove_trailing(tr_tier* tier, tr_announce_event e)
{
    while (!std::empty(tier->announce_events) && tier->announce_events.back() == e)
    {
        tier->announce_events.pop_back();
    }

    tier_update_announce_priority(tier);
}

void tier_announce_event_push(tr_tier* tier, tr_announce_event e, time_t announce_at)
{
    TR_ASSERT(tier != nullptr);

    tr_logAddTrace_tier_announce_queue(tier);
    tr_logAddTraceTier(tier, fmt::format("queued '{}'", tr_announce_event_get_string(e)));

    auto& events = tier->announce_events;
    if (!std::empty(events))
    {
        /* special case #1: if we're adding a "stopped" event,
         * dump everything leading up to it except "completed" */
        if (e == TR_ANNOUNCE_EVENT_STOPPED)
        {
            auto const has_completed = std::ranges::find(events, TR_ANNOUNCE_EVENT_COMPLETED) != std::ranges::end(events);
            events.clear();
            if (has_completed)
            {
                events.push_back(TR_ANNOUNCE_EVENT_COMPLETED);
            }
        }

        /* special case #2: dump all empty strings leading up to this event */
        tier_announce_remove_trailing(tier, TR_ANNOUNCE_EVENT_NONE);

        /* special case #3: no consecutive duplicates */
        tier_announce_remove_trailing(tier, e);
    }

    /* add it */
    events.push_back(e);
    tier->announceAt = announce_at;
    tier_update_announce_priority(tier);

    tr_logAddTrace_tier_announce_queue(tier);
    tr_logAddTraceTier(tier, fmt::format("announcing in {} seconds", difftime(announce_at, tr_time())));
}

auto tier_announce_event_pull(tr_tier* tier)
{
    auto const e = tier->announce_events.front();
    tier->announce_events.pop_front();
    tier_update_announce_priority(tier);
    return e;
}

bool isUnregistered(char const* errmsg)
{
    auto const lower = tr_strlower(errmsg != nullptr ? errmsg : "");

    auto constexpr Keys = std::array<std::string_view, 2>{ "unregistered torrent"sv, "torrent not registered"sv };

    return std::ranges::any_of(Keys, [&lower](auto const& key) { return tr_strv_contains(lower, key); });
}

void on_announce_error(tr_tier* tier, char const* err, tr_announce_event e, time_t interval = {})
{
    using namespace announce_helpers;

    auto* current_tracker = tier->currentTracker();
    auto const announce_url = current_tracker != nullptr ? tr_urlTrackerLogName(current_tracker->announce_url) : "nullptr"s;

    /* increment the error count */
    if (current_tracker != nullptr)
    {
        ++current_tracker->consecutive_failures;
    }

    /* set the error message */
    tier->last_announce_str = err;

    /* switch to the next tracker */
    current_tracker = tier->useNextTracker();

    if (isUnregistered(err))
    {
        tr_logAddErrorTier(
            tier,
            fmt::format(
                fmt::runtime(_("Announce error: {error} ({url})")),
                fmt::arg("error", err),
                fmt::arg("url", announce_url)));
    }
    else
    {
        /* schedule a reannounce */
        interval = std::max(interval, current_tracker->getRetryInterval());
        tr_logAddWarnTier(
            tier,
            fmt::format(
                fmt::runtime(tr_ngettext(
                    "Announce error: {error} (Retrying in {count} second) ({url})",
                    "Announce error: {error} (Retrying in {count} seconds) ({url})",
                    interval)),
                fmt::arg("error", err),
                fmt::arg("count", interval),
                fmt::arg("url", announce_url)));
        tier_announce_event_push(tier, e, tr_time() + interval);
    }
}

[[nodiscard]] tr_announce_request create_announce_request(
    tr_announcer_impl const* const announcer,
    tr_torrent* const tor,
    tr_tier const* const tier,
    tr_announce_event const event)
{
    auto const* const current_tracker = tier->currentTracker();
    TR_ASSERT(current_tracker != nullptr);

    return {
        .event = event,
        .partial_seed = tor->is_partial_seed(),
        .port = announcer->session->advertisedPeerPort(),
        .key = tor->announce_key(),
        .numwant = event == TR_ANNOUNCE_EVENT_STOPPED ? 0 : Numwant,
        .up = tier->byteCounts[TR_ANN_UP],
        .down = tier->byteCounts[TR_ANN_DOWN],
        .corrupt = tier->byteCounts[TR_ANN_CORRUPT],
        .leftUntilComplete = tor->has_metainfo() ? tor->total_size() - tor->has_total() : INT64_MAX,
        .announce_url = current_tracker->announce_url,
        .tracker_id = current_tracker->tracker_id,
        .peer_id = tor->peer_id(),
        .info_hash = tor->info_hash(),
        .log_name = tier->buildLogName(),
    };
}

[[nodiscard]] tr_tier* getTier(tr_announcer_impl* announcer, tr_sha1_digest_t const& info_hash, size_t tier_id)
{
    if (announcer == nullptr)
    {
        return nullptr;
    }

    auto* const tor = announcer->session->torrents().get(info_hash);
    if (tor == nullptr || tor->torrent_announcer == nullptr)
    {
        return nullptr;
    }

    return tor->torrent_announcer->getTier(tier_id);
}
} // namespace announce_helpers

void torrentAddAnnounce(tr_torrent* tor, tr_announce_event e, time_t announce_at)
{
    using namespace announce_helpers;

    // tell each tier to announce
    for (auto& tier : tor->torrent_announcer->tiers)
    {
        tier_announce_event_push(&tier, e, announce_at);
    }
}
} // namespace

void tr_announcer_impl::onAnnounceDone(
    size_t tier_id,
    tr_announce_event event,
    bool is_running_on_success,
    tr_announce_response const& response)
{
    using namespace announce_helpers;
    using namespace publish_helpers;

    auto* const tier = getTier(this, response.info_hash, tier_id);
    if (tier == nullptr)
    {
        return;
    }

    auto const now = tr_time();

    tr_logAddTraceTier(
        tier,
        fmt::format(
            "Got announce response: "
            "connected:{} "
            "timeout:{} "
            "seeders:{} "
            "leechers:{} "
            "downloads:{} "
            "interval:{} "
            "min_interval:{} "
            "tracker_id_str:{} "
            "pex:{} "
            "pex6:{} "
            "err:{} "
            "warn:{}",
            response.did_connect,
            response.did_timeout,
            response.seeders.value_or(-1),
            response.leechers.value_or(-1),
            response.downloads.value_or(-1),
            response.interval,
            response.min_interval,
            (!std::empty(response.tracker_id) ? response.tracker_id.c_str() : "none"),
            std::size(response.pex),
            std::size(response.pex6),
            (!std::empty(response.errmsg) ? response.errmsg.c_str() : "none"),
            (!std::empty(response.warning) ? response.warning.c_str() : "none")));

    // https://github.com/arvidn/libtorrent/issues/5084#issuecomment-688516452
    if (response.min_interval != 0)
    {
        tier->announceMinIntervalSec = response.min_interval;
    }

    if (response.interval != 0)
    {
        tier->announceIntervalSec = response.interval;
    }

    tier->lastAnnounceTime = now;
    tier->lastAnnounceTimedOut = response.did_timeout;
    tier->lastAnnounceSucceeded = false;
    tier->isAnnouncing = false;
    tier->manualAnnounceAllowedAt = now + tier->announceMinIntervalSec;

    if (response.external_ip)
    {
        session->set_global_address(*response.external_ip);
    }

    if (!response.did_connect)
    {
        on_announce_error(tier, _("Could not connect to tracker"), event);
    }
    else if (response.did_timeout)
    {
        on_announce_error(tier, _("Tracker did not respond"), event);
    }
    else if (!std::empty(response.errmsg))
    {
        /* If the torrent's only tracker returned an error, publish it.
           Don't bother publishing if there are other trackers -- it's
           all too common for people to load up dozens of dead trackers
           in a torrent's metainfo... */
        if (std::size(tier->tor->announce_list()) < 2U)
        {
            publishError(tier, response.errmsg);
        }

        on_announce_error(
            tier,
            response.errmsg.c_str(),
            event,
            response.interval > time_t{} ? response.interval : response.min_interval);
    }
    else
    {
        auto const is_stopped = event == TR_ANNOUNCE_EVENT_STOPPED;
        auto scrape_fields = uint8_t{};

        publishErrorClear(tier);

        auto* const tracker = tier->currentTracker();
        if (tracker != nullptr)
        {
            tracker->consecutive_failures = 0;

            if (tracker->set_seeder_count(response.seeders))
            {
                ++scrape_fields;
            }

            if (tracker->set_leecher_count(response.leechers))
            {
                ++scrape_fields;
            }

            if (tracker->set_download_count(response.downloads))
            {
                ++scrape_fields;
            }

            if (!std::empty(response.tracker_id))
            {
                tracker->tracker_id = response.tracker_id;
            }
        }

        if (auto const& warning = response.warning; !std::empty(warning))
        {
            tier->last_announce_str = warning;
            tr_logAddTraceTier(tier, fmt::format("tracker gave '{}'", warning));
            publishWarning(tier, warning);
        }
        else
        {
            tier->last_announce_str = _("Success");
        }

        if (!std::empty(response.pex))
        {
            publishPeersPex(tier, response.pex);
        }

        if (!std::empty(response.pex6))
        {
            publishPeersPex(tier, response.pex6);
        }

        publishPeerCounts(tier, response.seeders, response.leechers, {});

        tier->isRunning = is_running_on_success;

        /* if the tracker included scrape fields in its announce response,
           then a separate scrape isn't needed */
        if (scrape_fields >= 3U || (scrape_fields >= 1U && tracker->scrape_info == nullptr))
        {
            tr_logAddTraceTier(
                tier,
                fmt::format(
                    "Announce response has scrape info; bumping next scrape to {} seconds from now.",
                    tier->scrapeIntervalSec));
            tier->scheduleNextScrape();
            tier->lastScrapeTime = now;
            tier->lastScrapeSucceeded = true;
        }
        else if (tier->lastScrapeTime + tier->scrapeIntervalSec <= now)
        {
            tier->scrapeSoon();
        }

        tier->lastAnnounceSucceeded = true;
        tier->lastAnnouncePeerCount = std::size(response.pex) + std::size(response.pex6);

        if (is_stopped)
        {
            /* now that we've successfully stopped the torrent,
             * we can reset the up/down/corrupt count we've kept
             * for this tracker */
            tier->byteCounts[TR_ANN_UP] = 0;
            tier->byteCounts[TR_ANN_DOWN] = 0;
            tier->byteCounts[TR_ANN_CORRUPT] = 0;
        }

        if (!is_stopped && std::empty(tier->announce_events))
        {
            /* the queue is empty, so enqueue a periodic update */
            auto const i = tier->announceIntervalSec;
            tr_logAddTraceTier(tier, fmt::format("Sending periodic reannounce in {} seconds", i));
            tier_announce_event_push(tier, TR_ANNOUNCE_EVENT_NONE, now + i);
        }
    }
}

void tr_announcer_impl::startTorrent(tr_torrent* tor)
{
    torrentAddAnnounce(tor, TR_ANNOUNCE_EVENT_STARTED, tr_time());
}

void tr_announcerManualAnnounce(tr_torrent* tor)
{
    torrentAddAnnounce(tor, TR_ANNOUNCE_EVENT_NONE, tr_time());
}

void tr_announcer_impl::stopTorrent(tr_torrent* tor)
{
    torrentAddAnnounce(tor, TR_ANNOUNCE_EVENT_STOPPED, tr_time());
}

void tr_announcerTorrentCompleted(tr_torrent* tor)
{
    torrentAddAnnounce(tor, TR_ANNOUNCE_EVENT_COMPLETED, tr_time());
}

void tr_announcerChangeMyPort(tr_torrent* tor)
{
    torrentAddAnnounce(tor, TR_ANNOUNCE_EVENT_STARTED, tr_time());
}

// ---

void tr_announcerAddBytes(tr_torrent* tor, int type, uint32_t n_bytes)
{
    TR_ASSERT(tr_isTorrent(tor));
    TR_ASSERT(type == TR_ANN_UP || type == TR_ANN_DOWN || type == TR_ANN_CORRUPT);

    for (auto& tier : tor->torrent_announcer->tiers)
    {
        tier.byteCounts[type] += n_bytes;
    }
}

// ---

void tr_announcer_impl::removeTorrent(tr_torrent* tor)
{
    using namespace announce_helpers;

    // FIXME(ckerr)
    auto* const ta = tor->torrent_announcer;
    if (ta == nullptr)
    {
        return;
    }

    for (auto const& tier : ta->tiers)
    {
        if (tier.isRunning && tier.lastAnnounceSucceeded)
        {
            stops_.emplace(create_announce_request(this, tor, &tier, TR_ANNOUNCE_EVENT_STOPPED));
        }
    }

    tor->torrent_announcer = nullptr;
    delete ta;
}

// --- SCRAPE

namespace
{
namespace on_scrape_done_helpers
{
[[nodiscard]] constexpr bool multiscrape_too_big(std::string_view errmsg)
{
    /* Found a tracker that returns some bespoke string for this case?
       Add your patch here and open a PR */
    auto too_long_errors = std::array<std::string_view, 3>{
        "Bad Request",
        "GET string too long",
        "Request-URI Too Long",
    };

    return std::ranges::any_of(too_long_errors, [&errmsg](auto const& substr) { return tr_strv_contains(errmsg, substr); });
}

void on_scrape_error(tr_session const* /*session*/, tr_tier* tier, char const* errmsg)
{
    if (auto* const current_tracker = tier->currentTracker(); current_tracker != nullptr)
    {
        ++current_tracker->consecutive_failures;

        tr_logAddDebugTier(
            tier,
            fmt::format(
                "Tracker '{}' scrape error: {} (Can retry in {} seconds)",
                current_tracker->announce_parsed.authority,
                errmsg,
                current_tracker->getRetryInterval()));
    }

    // set the error message
    tier->last_scrape_str = errmsg != nullptr ? errmsg : "";
    tier->lastScrapeSucceeded = false;

    // switch to the next tracker
    if (auto* const current_tracker = tier->useNextTracker(); current_tracker != nullptr)
    {
        // schedule a rescrape
        tier->scheduleNextScrape(current_tracker->getRetryInterval());
    }
}

void checkMultiscrapeMax(tr_announcer_impl* announcer, tr_scrape_response const& response)
{
    if (!multiscrape_too_big(response.errmsg))
    {
        return;
    }

    auto const& url = response.scrape_url;
    auto* const scrape_info = announcer->scrape_info(url);
    if (scrape_info == nullptr)
    {
        return;
    }

    // Lower the max only if it hasn't already lowered for a similar
    // error. So if N parallel multiscrapes all have the same `max`
    // and error out, lower the value once for that batch, not N times.
    auto& multiscrape_max = scrape_info->multiscrape_max;
    if (multiscrape_max < response.row_count)
    {
        return;
    }

    auto const n = multiscrape_max > TrMultiscrapeStep ? multiscrape_max - TrMultiscrapeStep : 1U;
    if (multiscrape_max != n)
    {
        // don't log the full URL, since that might have a personal announce id
        tr_logAddDebug(fmt::format("Reducing multiscrape max to {:d}", n), tr_urlTrackerLogName(url));

        multiscrape_max = n;
    }
}
} // namespace on_scrape_done_helpers
} // namespace

void tr_announcer_impl::onScrapeDone(tr_scrape_response const& response)
{
    using namespace on_scrape_done_helpers;
    using namespace publish_helpers;

    auto const now = tr_time();

    for (size_t i = 0; i < response.row_count; ++i)
    {
        auto const& row = response.rows[i];

        auto* const tor = session->torrents().get(row.info_hash);
        if (tor == nullptr)
        {
            continue;
        }

        auto* const tier = tor->torrent_announcer->getTierFromScrape(response.scrape_url);
        if (tier == nullptr)
        {
            continue;
        }

        tr_logAddTraceTier(
            tier,
            fmt::format(
                "scraped url:{} "
                " -- "
                "did_connect:{} "
                "did_timeout:{} "
                "seeders:{} "
                "leechers:{} "
                "downloads:{} "
                "downloaders:{} "
                "min_request_interval:{} "
                "err:{} ",
                response.scrape_url.sv(),
                response.did_connect,
                response.did_timeout,
                row.seeders.value_or(-1),
                row.leechers.value_or(-1),
                row.downloads.value_or(-1),
                row.downloaders.value_or(-1),
                response.min_request_interval,
                std::empty(response.errmsg) ? "none"sv : response.errmsg));

        tier->isScraping = false;
        tier->lastScrapeTime = now;
        tier->lastScrapeSucceeded = false;
        tier->lastScrapeTimedOut = response.did_timeout;

        if (!response.did_connect)
        {
            on_scrape_error(session, tier, _("Could not connect to tracker"));
        }
        else if (response.did_timeout)
        {
            on_scrape_error(session, tier, _("Tracker did not respond"));
        }
        else if (!std::empty(response.errmsg))
        {
            on_scrape_error(session, tier, response.errmsg.c_str());
        }
        else
        {
            tier->lastScrapeSucceeded = true;
            tier->scrapeIntervalSec = std::max(DefaultScrapeIntervalSec, response.min_request_interval);
            tier->scheduleNextScrape();
            tr_logAddTraceTier(tier, fmt::format("Scrape successful. Rescraping in {} seconds.", tier->scrapeIntervalSec));

            if (tr_tracker* const tracker = tier->currentTracker(); tracker != nullptr)
            {
                tracker->set_seeder_count(row.seeders);
                tracker->set_leecher_count(row.leechers);
                tracker->set_download_count(row.downloads);
                tracker->set_downloader_count(row.downloaders);

                tracker->consecutive_failures = 0;
            }

            publishPeerCounts(tier, row.seeders, row.leechers, row.downloaders);
        }
    }

    checkMultiscrapeMax(this, response);
}

namespace
{
void multiscrape(tr_announcer_impl* announcer, std::vector<tr_tier*> const& tiers)
{
    auto const now = tr_time();
    auto requests = std::array<tr_scrape_request, MaxScrapesPerUpkeep>{};
    auto request_count = size_t{};

    // batch as many info_hashes into a request as we can
    for (auto* tier : tiers)
    {
        auto const* const current_tracker = tier->currentTracker();
        TR_ASSERT(current_tracker != nullptr);
        if (current_tracker == nullptr)
        {
            continue;
        }

        auto const* const scrape_info = current_tracker->scrape_info;
        TR_ASSERT(scrape_info != nullptr);
        if (scrape_info == nullptr)
        {
            continue;
        }

        bool found = false;

        /* if there's a request with this scrape URL and a free slot, use it */
        for (size_t j = 0; !found && j < request_count; ++j)
        {
            auto* const req = &requests[j];

            if (req->info_hash_count >= scrape_info->multiscrape_max)
            {
                continue;
            }

            if (scrape_info->scrape_url != req->scrape_url)
            {
                continue;
            }

            req->info_hash[req->info_hash_count] = tier->tor->info_hash();
            ++req->info_hash_count;
            tier->isScraping = true;
            tier->lastScrapeStartTime = now;
            found = true;
        }

        /* otherwise, if there's room for another request, build a new one */
        if (!found && request_count < MaxScrapesPerUpkeep)
        {
            auto* const req = &requests[request_count];
            req->scrape_url = scrape_info->scrape_url;
            req->log_name = tier->buildLogName();

            req->info_hash[req->info_hash_count] = tier->tor->info_hash();
            ++req->info_hash_count;
            tier->isScraping = true;
            tier->lastScrapeStartTime = now;

            ++request_count;
        }
    }

    /* send the requests we just built */
    for (size_t i = 0; i < request_count; ++i)
    {
        announcer->scrape(
            requests[i],
            [session = announcer->session, announcer](tr_scrape_response const& response)
            {
                if (session->announcer_)
                {
                    announcer->onScrapeDone(response);
                }
            });
    }
}

namespace upkeep_helpers
{
int compareAnnounceTiers(tr_tier const* a, tr_tier const* b)
{
    /* prefer higher-priority events */
    if (auto const val = tr_compare_3way(a->announce_event_priority, b->announce_event_priority); val != 0)
    {
        return -val;
    }

    /* prefer swarms where we might upload */
    if (auto const val = tr_compare_3way(a->countDownloaders(), b->countDownloaders()); val != 0)
    {
        return -val;
    }

    /* prefer swarms where we might download */
    if (auto const val = tr_compare_3way(a->tor->is_done(), b->tor->is_done()); val != 0)
    {
        return val;
    }

    /* prefer larger stats, to help ensure stats get recorded when stopping on shutdown */
    if (auto const val = tr_compare_3way(
            a->byteCounts[TR_ANN_UP] + a->byteCounts[TR_ANN_DOWN],
            b->byteCounts[TR_ANN_UP] + b->byteCounts[TR_ANN_DOWN]);
        val != 0)
    {
        return -val;
    }

    // announcements that have been waiting longer go first
    if (auto const val = tr_compare_3way(a->announceAt, b->announceAt); val != 0)
    {
        return val;
    }

    // the tiers are effectively equal priority, but add an arbitrary
    // differentiation because ptrArray sorted mode hates equal items.
    return tr_compare_3way(a, b);
}

void tierAnnounce(tr_announcer_impl* announcer, tr_tier* tier)
{
    using namespace announce_helpers;

    TR_ASSERT(!tier->isAnnouncing);
    TR_ASSERT(!std::empty(tier->announce_events));
    TR_ASSERT(tier->currentTracker() != nullptr);

    auto const now = tr_time();

    tr_torrent* tor = tier->tor;
    auto const event = tier_announce_event_pull(tier);
    auto const req = create_announce_request(announcer, tor, tier, event);

    tier->isAnnouncing = true;
    tier->lastAnnounceStartTime = now;

    auto tier_id = tier->id;
    auto is_running_on_success = tor->is_running();

    announcer->announce(
        req,
        [session = announcer->session, announcer, tier_id, event, is_running_on_success](tr_announce_response const& response)
        {
            if (session->announcer_)
            {
                announcer->onAnnounceDone(tier_id, event, is_running_on_success, response);
            }
        });
}

void scrapeAndAnnounceMore(tr_announcer_impl* announcer)
{
    auto const now = tr_time();

    /* build a list of tiers that need to be announced */
    auto announce_me = std::vector<tr_tier*>{};
    auto scrape_me = std::vector<tr_tier*>{};
    for (auto* const tor : announcer->session->torrents())
    {
        for (auto& tier : tor->torrent_announcer->tiers)
        {
            if (tier.needsToAnnounce(now))
            {
                announce_me.push_back(&tier);
            }

            if (tier.needsToScrape(now))
            {
                scrape_me.push_back(&tier);
            }
        }
    }

    /* First, scrape what we can. We handle scrapes first because
     * we can work through that queue much faster than announces
     * (thanks to multiscrape) _and_ the scrape responses will tell
     * us which swarms are interesting and should be announced next. */
    multiscrape(announcer, scrape_me);

    /* Second, announce what we can. If there aren't enough slots
     * available, use compareAnnounceTiers to prioritize. */
    if (announce_me.size() > MaxAnnouncesPerUpkeep)
    {
        std::partial_sort(
            std::begin(announce_me),
            std::begin(announce_me) + MaxAnnouncesPerUpkeep,
            std::end(announce_me),
            [](auto const* a, auto const* b) { return compareAnnounceTiers(a, b) < 0; });
        announce_me.resize(MaxAnnouncesPerUpkeep);
    }

    for (auto*& tier : announce_me)
    {
        tr_logAddTraceTier(tier, "Announcing to tracker");
        tierAnnounce(announcer, tier);
    }
}
} // namespace upkeep_helpers
} // namespace

void tr_announcer_impl::upkeep()
{
    using namespace upkeep_helpers;

    auto const lock = session->unique_lock();

    // maybe send out some "stopped" messages for closed torrents
    flushCloseMessages();

    // maybe kick off some scrapes / announces whose time has come
    if (!is_shutting_down_)
    {
        scrapeAndAnnounceMore(this);
    }

    announcer_udp_.upkeep();
}

// ---

namespace
{
namespace tracker_view_helpers
{
[[nodiscard]] auto trackerView(tr_torrent const& tor, size_t tier_index, tr_tier const& tier, tr_tracker const& tracker)
{
    auto const& announce = tracker.announce_parsed;
    auto const now = tr_time();
    auto view = tr_tracker_view{};

    *fmt::format_to_n(
         std::data(view.host_and_port),
         std::size(view.host_and_port) - 1U,
         "{:s}:{:d}",
         announce.host,
         announce.port)
         .out = '\0';
    *fmt::format_to_n(std::data(view.sitename), std::size(view.sitename) - 1U, "{:s}", announce.sitename).out = '\0';
    view.announce = tracker.announce_url.c_str();
    view.scrape = tracker.scrape_info == nullptr ? "" : tracker.scrape_info->scrape_url.c_str();
    view.id = tracker.id;
    view.tier = tier_index;
    view.isBackup = &tracker != tier.currentTracker();
    view.lastScrapeStartTime = tier.lastScrapeStartTime;
    view.seederCount = tracker.seeder_count().value_or(-1);
    view.leecherCount = tracker.leecher_count().value_or(-1);
    view.downloadCount = tracker.download_count().value_or(-1);
    view.downloader_count = tracker.downloader_count().value_or(-1);

    if (view.isBackup)
    {
        view.scrapeState = TR_TRACKER_INACTIVE;
        view.announceState = TR_TRACKER_INACTIVE;
        view.nextScrapeTime = 0;
        view.nextAnnounceTime = 0;
    }
    else
    {
        view.hasScraped = tier.lastScrapeTime != 0;
        if (view.hasScraped)
        {
            view.lastScrapeTime = tier.lastScrapeTime;
            view.lastScrapeSucceeded = tier.lastScrapeSucceeded;
            view.lastScrapeTimedOut = tier.lastScrapeTimedOut;
            auto& buf = view.lastScrapeResult;
            *fmt::format_to_n(buf, sizeof(buf) - 1, "{:s}", tier.last_scrape_str).out = '\0';
        }

        if (tier.isScraping)
        {
            view.scrapeState = TR_TRACKER_ACTIVE;
        }
        else if (tier.scrapeAt == 0)
        {
            view.scrapeState = TR_TRACKER_INACTIVE;
        }
        else if (tier.scrapeAt > now)
        {
            view.scrapeState = TR_TRACKER_WAITING;
            view.nextScrapeTime = tier.scrapeAt;
        }
        else
        {
            view.scrapeState = TR_TRACKER_QUEUED;
        }

        view.lastAnnounceStartTime = tier.lastAnnounceStartTime;

        view.hasAnnounced = tier.lastAnnounceTime != 0;
        if (view.hasAnnounced)
        {
            view.lastAnnounceTime = tier.lastAnnounceTime;
            view.lastAnnounceSucceeded = tier.lastAnnounceSucceeded;
            view.lastAnnounceTimedOut = tier.lastAnnounceTimedOut;
            view.lastAnnouncePeerCount = tier.lastAnnouncePeerCount;
            auto& buf = view.lastAnnounceResult;
            *fmt::format_to_n(buf, sizeof(buf) - 1, "{:s}", tier.last_announce_str).out = '\0';
        }

        if (tier.isAnnouncing)
        {
            view.announceState = TR_TRACKER_ACTIVE;
        }
        else if (!tor.is_running() || tier.announceAt == 0)
        {
            view.announceState = TR_TRACKER_INACTIVE;
        }
        else if (tier.announceAt > now)
        {
            view.announceState = TR_TRACKER_WAITING;
            view.nextAnnounceTime = tier.announceAt;
        }
        else
        {
            view.announceState = TR_TRACKER_QUEUED;
        }
    }

    return view;
}
} // namespace tracker_view_helpers
} // namespace

size_t tr_announcerTrackerCount(tr_torrent const* tor)
{
    TR_ASSERT(tr_isTorrent(tor));
    TR_ASSERT(tor->torrent_announcer != nullptr);

    auto const& tiers = tor->torrent_announcer->tiers;
    return std::accumulate(
        std::begin(tiers),
        std::end(tiers),
        size_t{},
        [](size_t acc, auto const& cur) { return acc + std::size(cur.trackers); });
}

tr_tracker_view tr_announcerTracker(tr_torrent const* tor, size_t nth)
{
    using namespace tracker_view_helpers;

    TR_ASSERT(tr_isTorrent(tor));
    TR_ASSERT(tor->torrent_announcer != nullptr);

    auto tier_index = size_t{ 0 };
    auto tracker_index = size_t{ 0 };
    for (auto const& tier : tor->torrent_announcer->tiers)
    {
        for (auto const& tracker : tier.trackers)
        {
            if (tracker_index == nth)
            {
                return trackerView(*tor, tier_index, tier, tracker);
            }

            ++tracker_index;
        }

        ++tier_index;
    }

    TR_ASSERT(false);
    return {};
}

// ---

// called after the torrent's announceList was rebuilt --
// so announcer needs to update the tr_tier / tr_trackers to match
void tr_announcer_impl::resetTorrent(tr_torrent* tor)
{
    using namespace announce_helpers;

    // make a new tr_announcer_tier
    auto* const older = tor->torrent_announcer;
    tor->torrent_announcer = new tr_torrent_announcer{ this, tor };
    auto* const newer = tor->torrent_announcer;

    // copy the tracker counts into the new replacementa
    if (older != nullptr)
    {
        for (auto& new_tier : newer->tiers)
        {
            for (auto& new_tracker : new_tier.trackers)
            {
                tr_tier const* old_tier = nullptr;
                tr_tracker const* old_tracker = nullptr;
                if (older->findTracker(new_tracker.announce_url, &old_tier, &old_tracker))
                {
                    new_tracker.set_seeder_count(old_tracker->seeder_count());
                    new_tracker.set_leecher_count(old_tracker->leecher_count());
                    new_tracker.set_download_count(old_tracker->download_count());
                    new_tracker.set_downloader_count(old_tracker->downloader_count());

                    new_tier.announce_events = old_tier->announce_events;
                    new_tier.announce_event_priority = old_tier->announce_event_priority;

                    auto const* const old_current = old_tier->currentTracker();
                    new_tier.current_tracker_index_ = old_current == nullptr ? std::nullopt :
                                                                               new_tier.indexOf(old_current->announce_url);
                }
            }
        }
    }

    // kickstart any tiers that didn't get started
    if (tor->is_running())
    {
        auto const now = tr_time();
        for (auto& tier : newer->tiers)
        {
            if (!tier.current_tracker_index_)
            {
                tier.useNextTracker();
                tier_announce_event_push(&tier, TR_ANNOUNCE_EVENT_STARTED, now);
            }
        }
    }

    delete older;
}
