#include <doctest/doctest.h>

#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"

#include <algorithm>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>

using namespace ccm;

namespace {

// Single-shot HTTP fake matching the other game tests. Captures the last URL
// requested and returns a configurable body / failure flag.
class FixedHttpClient final : public IHttpClient {
public:
    std::string lastUrl;
    std::string body;
    bool ok = true;
    Result<std::string> get(std::string_view url) override {
        lastUrl = std::string(url);
        return ok ? Result<std::string>::ok(body)
                  : Result<std::string>::err("offline");
    }
};

// Routing fake used to simulate the two-step Yugipedia -> YGOPRODeck flow.
// Both bodies are queued by URL substring so order-of-call assertions stay
// readable in the tests. Unmatched URLs return a 404-equivalent error so
// regressions are obvious instead of silently passing.
class RoutingHttpClient final : public IHttpClient {
public:
    std::string yugipediaBody;
    std::string ygoprodeckBody;
    bool yugipediaOk = true;
    bool ygoprodeckOk = true;
    std::vector<std::string> calls;
    Result<std::string> get(std::string_view url) override {
        std::string s(url);
        calls.push_back(s);
        if (s.find("yugipedia.com") != std::string::npos) {
            return yugipediaOk ? Result<std::string>::ok(yugipediaBody)
                               : Result<std::string>::err("yugipedia offline");
        }
        if (s.find("ygoprodeck.com") != std::string::npos) {
            return ygoprodeckOk ? Result<std::string>::ok(ygoprodeckBody)
                                : Result<std::string>::err("ygoprodeck offline");
        }
        return Result<std::string>::err("RoutingHttpClient: unrouted URL");
    }
};

// First GET (filtered `cardset=` URL) fails; second GET (unfiltered) succeeds.
// Exercises `YuGiOhCardPreviewSource::detectPrintVariants` narrow-query fallback.
class FailFilteredThenOkHttpClient final : public IHttpClient {
public:
    std::string unfilteredBody;
    int calls{0};

    Result<std::string> get(std::string_view url) override {
        ++calls;
        std::string u(url);
        if (u.find("cardset=") != std::string::npos) {
            return Result<std::string>::err("filtered endpoint unavailable");
        }
        return Result<std::string>::ok(unfilteredBody);
    }
};

}  // namespace

TEST_SUITE("ygoPrintingSlotsMatch") {
    TEST_CASE("matches CCM composed setNo to YGOPRODeck set_code with region infix") {
        CHECK(ygoPrintingSlotsMatch("SOD-015", "SOD-EN015"));
        CHECK(ygoPrintingSlotsMatch("sod-015", "SOD-EN015"));
        CHECK_FALSE(ygoPrintingSlotsMatch("SOD-015", "SOD-EN016"));
        CHECK_FALSE(ygoPrintingSlotsMatch("SOD-015", "IOC-EN015"));
    }

    TEST_CASE("matches German-style and digits-only suffix variants") {
        CHECK(ygoPrintingSlotsMatch("LOB-005", "LOB-DE005"));
        CHECK(ygoPrintingSlotsMatch("RA04-001", "RA04-EN001"));
    }

    TEST_CASE("matches collector numbers ignoring leading zeros") {
        CHECK(ygoPrintingSlotsMatch("LOB-5", "LOB-005"));
        CHECK(ygoPrintingSlotsMatch("LOB-EN005", "LOB-5"));
        CHECK(ygoCollectorDigitsEqual("005", "5"));
        CHECK(ygoCollectorDigitsEqual("LOB-EN005", "5"));
        CHECK(ygoCollectorDigitsFromInput("005") == "005");
        CHECK(ygoCollectorDigitsFromInput("LOB-EN005") == "005");
    }

    TEST_CASE("detects European alternate numbering suffix E+digit vs EN/DE") {
        CHECK(ygoLikelyEuropeanRegionalSetCode("LOB-E003"));
        CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-EN005"));
        CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-005"));
        CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-DE005"));
        CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("SOD-EN015"));
        CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-E"));
        CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-EX005"));
    }
}

TEST_SUITE("YuGiOhPrintingSlot helpers") {
    TEST_CASE("trimAsciiSpaces handles empty and surrounding whitespace") {
        CHECK(trimAsciiSpaces("").empty());
        CHECK(trimAsciiSpaces("   ").empty());
        CHECK(trimAsciiSpaces("  LOB-005  ") == "LOB-005");
    }

    TEST_CASE("ygoAbbrevBeforeDash and ygoCollectorDigitsOnly cover no-dash and mixed tails") {
        CHECK(ygoAbbrevBeforeDash("lob") == "lob");
        CHECK(ygoAbbrevBeforeDash("  SOD-015  ") == "sod");
        CHECK(ygoCollectorDigitsOnly("SOD").empty());
        CHECK(ygoCollectorDigitsOnly("SOD-EN015") == "015");
        CHECK(ygoCollectorDigitsOnly("SOD-ABC") == "");
    }
}

TEST_SUITE("ygoRarityShortCode") {
    TEST_CASE("maps supported Yu-Gi-Oh rarity names to short form") {
        CHECK(ygoRarityShortCode("Common") == "C");
        CHECK(ygoRarityShortCode("Rare") == "R");
        CHECK(ygoRarityShortCode("Super Rare") == "SR");
        CHECK(ygoRarityShortCode("Ultra Rare") == "UR");
        CHECK(ygoRarityShortCode("Secret Rare") == "ScR");
        CHECK(ygoRarityShortCode("Quarter Century Secret Rare") == "QCScR");
        CHECK(ygoRarityShortCode("Starlight Rare") == "StR");
        CHECK(ygoRarityShortCode("Collector's Rare") == "CR");
        CHECK(ygoRarityShortCode("Ghost Rare") == "GR");
        CHECK(ygoRarityShortCode("Ultimate Rare") == "UtR");
        CHECK(ygoRarityShortCode("Platinum Secret Rare") == "PlScR");
        CHECK(ygoRarityShortCode("Prismatic Secret Rare") == "PScR");
    }

    TEST_CASE("normalizes punctuation and spacing and accepts QCSR alias") {
        CHECK(ygoRarityShortCode("Ultra-Rare") == "UR");
        CHECK(ygoRarityShortCode("Collector`s Rare") == "CR");
        CHECK(ygoRarityShortCode("QCSR") == "QCScR");
    }

    TEST_CASE("unknown rarity returns empty") {
        CHECK(ygoRarityShortCode("Mythic Cosmic Rare").empty());
    }
}

TEST_SUITE("YuGiOhCardPreviewSource::normalizeName") {
    TEST_CASE("strips whitespace and policy-banned punctuation") {
        // Yugipedia's image policy: whitespace and a fixed punctuation set
        // (#,.:'"?!&@%=[]<>/\- and friends) get dropped from the slug.
        CHECK(YuGiOhCardPreviewSource::normalizeName("Blue-Eyes White Dragon")
              == "BlueEyesWhiteDragon");
        CHECK(YuGiOhCardPreviewSource::normalizeName("Dark Magician") == "DarkMagician");
        CHECK(YuGiOhCardPreviewSource::normalizeName("Sasuke Samurai #4")
              == "SasukeSamurai4");
        CHECK(YuGiOhCardPreviewSource::normalizeName("Don't Talk to Me!")
              == "DontTalktoMe");
    }
}

TEST_SUITE("YuGiOhCardPreviewSource::rarityCodeFor") {
    TEST_CASE("maps the dialog's rarity options to Yugipedia codes") {
        CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Common") == "C");
        CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Rare") == "R");
        CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Super Rare") == "SR");
        CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Ultra Rare") == "UR");
        CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Secret Rare") == "ScR");
        CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Quarter Century Secret Rare")
              == "QCScR");
        CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Collector's Rare") == "CR");
        CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Platinum Secret Rare") == "PlScR");
    }
    TEST_CASE("returns empty string for unknown rarity names") {
        // Unknown rarity should fall through to the rarity-less filename
        // pattern, not throw and not return a misleading code.
        CHECK(YuGiOhCardPreviewSource::rarityCodeFor("").empty());
        CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Mythic Cosmic Rare").empty());
    }

    TEST_CASE("uses dialog synonym table when ygoRarityShortCode does not match") {
        CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Mosaic Rare") == "MSR");
        CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Parallel Rare") == "PR");
        CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Gold Rare") == "GUR");
    }
}

TEST_SUITE("YuGiOhCardPreviewSource::extractSetCode") {
    TEST_CASE("returns everything before the first dash") {
        CHECK(YuGiOhCardPreviewSource::extractSetCode("LOB-005") == "LOB");
        CHECK(YuGiOhCardPreviewSource::extractSetCode("LOB-DE005") == "LOB");
        CHECK(YuGiOhCardPreviewSource::extractSetCode("RA04-EN001") == "RA04");
    }
    TEST_CASE("returns the input unchanged when no dash is present") {
        CHECK(YuGiOhCardPreviewSource::extractSetCode("LOB") == "LOB");
        CHECK(YuGiOhCardPreviewSource::extractSetCode("").empty());
    }
}

TEST_SUITE("YuGiOhCardPreviewSource::buildCandidateFilenames") {
    TEST_CASE("primary candidate uses the printed edition + EN region + .png") {
        const auto names = YuGiOhCardPreviewSource::buildCandidateFilenames(
            "Blue-Eyes White Dragon", "LOB", "UR", /*firstEdition=*/false);
        REQUIRE_FALSE(names.empty());
        // Highest-priority filename: Yugipedia's modern English/UE/.png
        // shape, which is what most LOB-era reprints actually use.
        CHECK(names.front() == "BlueEyesWhiteDragon-LOB-EN-UR-UE.png");
    }
    TEST_CASE("includes 1E when the user marked the card as first edition") {
        const auto names = YuGiOhCardPreviewSource::buildCandidateFilenames(
            "Dark Magician", "SDY", "UR", /*firstEdition=*/true);
        REQUIRE_FALSE(names.empty());
        CHECK(names.front() == "DarkMagician-SDY-EN-UR-1E.png");
    }
    TEST_CASE("covers EN/NA/EU/AU regions and both png/jpg extensions") {
        const auto names = YuGiOhCardPreviewSource::buildCandidateFilenames(
            "Blue-Eyes White Dragon", "SDK", "UR", /*firstEdition=*/false);
        // Sanity: the standard SDK Blue-Eyes scan is hosted at NA-UR-UE.png,
        // so the candidate list must include that exact filename.
        bool foundNaUe = false;
        for (const auto& n : names) {
            if (n == "BlueEyesWhiteDragon-SDK-NA-UR-UE.png") foundNaUe = true;
        }
        CHECK(foundNaUe);
    }
    TEST_CASE("falls back to a rarity-less pattern for unknown rarities") {
        const auto names = YuGiOhCardPreviewSource::buildCandidateFilenames(
            "Token Card", "TKN", /*rarityCode=*/"", /*firstEdition=*/false);
        REQUIRE_FALSE(names.empty());
        // Rarity slot omitted -> filename has only one dash before edition.
        CHECK(names.front() == "TokenCard-TKN-EN-UE.png");
    }
    TEST_CASE("returns empty list when the slug or set code is empty") {
        // Don't waste an HTTP call on cards that haven't been filled in yet.
        CHECK(YuGiOhCardPreviewSource::buildCandidateFilenames("", "LOB", "UR", false).empty());
        CHECK(YuGiOhCardPreviewSource::buildCandidateFilenames("Dark Magician", "", "UR", false).empty());
    }
}

TEST_SUITE("YuGiOhCardPreviewSource::buildYugipediaQueryUrl") {
    TEST_CASE("encodes filenames into a single MediaWiki batch query") {
        const std::vector<std::string> names = {
            "BlueEyesWhiteDragon-LOB-EN-UR-UE.png",
            "BlueEyesWhiteDragon-LOB-NA-UR-UE.png",
        };
        const std::string url = YuGiOhCardPreviewSource::buildYugipediaQueryUrl(names);
        CHECK(url.find("https://yugipedia.com/api.php") == 0);
        CHECK(url.find("action=query") != std::string::npos);
        CHECK(url.find("prop=imageinfo") != std::string::npos);
        CHECK(url.find("iiprop=url") != std::string::npos);
        // The two filenames are joined with `|` (URL-encoded as %7C) and
        // each one is namespaced with `File:` (encoded `File%3A`).
        CHECK(url.find("File%3ABlueEyesWhiteDragon-LOB-EN-UR-UE.png") != std::string::npos);
        CHECK(url.find("%7CFile%3ABlueEyesWhiteDragon-LOB-NA-UR-UE.png") != std::string::npos);
    }
}

TEST_SUITE("YuGiOhCardPreviewSource::parseYugipediaResponse") {
    TEST_CASE("returns the URL of the highest-priority filename that exists") {
        // MediaWiki returns one entry per requested title. Missing files come
        // back with `"missing": ""` and no imageinfo; existing files carry an
        // imageinfo[0].url. Order matters: we must walk filenameOrder and
        // pick the first entry that resolved, not whichever MediaWiki listed
        // first in its hash-keyed `pages` object.
        const std::string body = R"({
          "query":{"pages":{
            "-1":{"title":"File:DarkMagician-SDY-EN-UR-1E.png","missing":""},
            "96018":{"title":"File:DarkMagician-SDY-NA-UR-UE.png",
                     "imageinfo":[{"url":"https://ms.yugipedia.com/abc/DarkMagician-SDY-NA-UR-UE.png"}]},
            "99999":{"title":"File:DarkMagician-SDY-EU-UR-UE.png",
                     "imageinfo":[{"url":"https://ms.yugipedia.com/eu/DarkMagician-SDY-EU-UR-UE.png"}]}
          }}
        })";
        const std::vector<std::string> order = {
            "DarkMagician-SDY-EN-UR-UE.png",   // missing from response entirely
            "DarkMagician-SDY-EN-UR-1E.png",   // present but `missing`
            "DarkMagician-SDY-NA-UR-UE.png",   // first existing one
            "DarkMagician-SDY-EU-UR-UE.png",
        };
        const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(body, order);
        REQUIRE(out.isOk());
        CHECK(out.value() == "https://ms.yugipedia.com/abc/DarkMagician-SDY-NA-UR-UE.png");
    }

    TEST_CASE("pure-miss response is classified as NotFound") {
        // Pure-miss response: every page is marked `missing`. The caller
        // (fetchImageUrl) treats this as "no Yugipedia scan" and triggers
        // the YGOPRODeck fallback. Yugipedia having explicitly answered
        // "no such file" is a clean negative (the upstream contract was
        // honored), not a transient failure.
        const std::string body = R"({
          "query":{"pages":{
            "-1":{"title":"File:Whatever-XYZ-EN-UR-UE.png","missing":""},
            "-2":{"title":"File:Whatever-XYZ-NA-UR-UE.png","missing":""}
          }}
        })";
        const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(
            body, {"Whatever-XYZ-EN-UR-UE.png", "Whatever-XYZ-NA-UR-UE.png"});
        REQUIRE(out.isErr());
        CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
    }

    TEST_CASE("malformed JSON is classified as Transient") {
        const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(
            "{not json", {"Anything-XYZ-EN-UR-UE.png"});
        REQUIRE(out.isErr());
        CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
    }

    TEST_CASE("missing top-level query object is Transient") {
        const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(
            R"({"not_query":{}})", {"File.png"});
        REQUIRE(out.isErr());
        CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
    }

    TEST_CASE("query.pages not an object is Transient") {
        const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(
            R"({"query":{"pages":[]}})", {"X.png"});
        REQUIRE(out.isErr());
        CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
    }

    TEST_CASE("page without imageinfo is treated as missing") {
        const std::string body = R"({
          "query":{"pages":{
            "1":{"title":"File:DarkMagician-LOB-EN-UR-UE.png"}
          }}
        })";
        const auto out = YuGiOhCardPreviewSource::parseYugipediaResponse(
            body, {"DarkMagician-LOB-EN-UR-UE.png"});
        REQUIRE(out.isErr());
        CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
    }
}

TEST_SUITE("YuGiOhCardPreviewSource::buildSearchUrl") {
    TEST_CASE("percent-encodes name and optional set name filter") {
        const auto url = YuGiOhCardPreviewSource::buildSearchUrl(
            "Dark Magician", "Legend of Blue Eyes White Dragon");
        CHECK(url.find("https://db.ygoprodeck.com/api/v7/cardinfo.php") == 0);
        CHECK(url.find("fname=Dark%20Magician") != std::string::npos);
        CHECK(url.find("cardset=Legend%20of%20Blue%20Eyes%20White%20Dragon") != std::string::npos);
    }

    TEST_CASE("omits cardset when set name is empty") {
        const auto url = YuGiOhCardPreviewSource::buildSearchUrl("Dark Magician", "");
        CHECK(url.find("cardset=") == std::string::npos);
    }
}

TEST_SUITE("YuGiOhCardPreviewSource::parseFallbackImageUrl") {
    TEST_CASE("missing data array is Transient") {
        const auto out = YuGiOhCardPreviewSource::parseFallbackImageUrl(R"({"meta":{}})", "Dark Magician");
        REQUIRE(out.isErr());
        CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
    }

    TEST_CASE("data present but not an array is Transient") {
        const auto out =
            YuGiOhCardPreviewSource::parseFallbackImageUrl(R"({"data":{}})", "X");
        REQUIRE(out.isErr());
        CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
    }

    TEST_CASE("empty data array is NotFound") {
        const auto out = YuGiOhCardPreviewSource::parseFallbackImageUrl(R"({"data":[]})", "X");
        REQUIRE(out.isErr());
        CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
    }

    TEST_CASE("prefers exact-name row with image_url_small when image_url absent") {
        const std::string json = R"({"data":[
          {"name":"Dark Magician Girl","card_images":[{"image_url_small":"https://small.only/a.jpg"}]},
          {"name":"Dark Magician","card_images":[{"image_url":"https://ignored/wrong.jpg"}]}
        ]})";
        const auto out = YuGiOhCardPreviewSource::parseFallbackImageUrl(json, "Dark Magician Girl");
        REQUIRE(out.isOk());
        CHECK(out.value() == "https://small.only/a.jpg");
    }

    TEST_CASE("exact-name match uses image_url_cropped when earlier slots absent") {
        const std::string json = R"({"data":[{
          "name":"Slifer",
          "card_images":[{"image_url_cropped":"https://crop/z.jpg"}]
        }]})";
        const auto out = YuGiOhCardPreviewSource::parseFallbackImageUrl(json, "Slifer");
        REQUIRE(out.isOk());
        CHECK(out.value() == "https://crop/z.jpg");
    }

    TEST_CASE("no exact name match falls back to first ranked card_images row") {
        const std::string json = R"({"data":[{
          "name":"Dark Magician Girl",
          "card_images":[{"image_url":"https://images/std-from-ranked-first.jpg"}]
        }]})";
        const auto out =
            YuGiOhCardPreviewSource::parseFallbackImageUrl(json, "Dark Magician");
        REQUIRE(out.isOk());
        CHECK(out.value() == "https://images/std-from-ranked-first.jpg");
    }

    TEST_CASE("matching cards without usable images is NotFound") {
        const std::string json = R"({"data":[{
          "name":"Empty Card",
          "card_images":[{}]
        }]})";
        const auto out =
            YuGiOhCardPreviewSource::parseFallbackImageUrl(json, "Empty Card");
        REQUIRE(out.isErr());
        CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
    }

    TEST_CASE("malformed JSON is Transient") {
        const auto out =
            YuGiOhCardPreviewSource::parseFallbackImageUrl("{not json", "Any");
        REQUIRE(out.isErr());
        CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
    }
}

TEST_SUITE("YuGiOhCardPreviewSource::parseFirstPrint") {
    TEST_CASE("returns first print for preferred set name") {
        const std::string json = R"({
          "data":[
            {"card_sets":[
              {"set_name":"Metal Raiders","set_code":"MRD-001","set_rarity":"Ultra Rare"},
              {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-001","set_rarity":"Ultra Rare"}
            ]}
          ]
        })";
        const auto out = YuGiOhCardPreviewSource::parseFirstPrint(
            json, "Legend of Blue Eyes White Dragon");
        REQUIRE(out.isOk());
        CHECK(out.value().setNo == "LOB-001");
        CHECK(out.value().rarity == "Ultra Rare");
    }

    TEST_CASE("empty data array yields error") {
        const auto out =
            YuGiOhCardPreviewSource::parseFirstPrint(R"({"data":[]})", "Any Set");
        CHECK(out.isErr());
    }

    TEST_CASE("card row without card_sets yields error") {
        const auto out = YuGiOhCardPreviewSource::parseFirstPrint(
            R"({"data":[{"name":"Solo"}]})", "Any Display Set");
        CHECK(out.isErr());
    }
}

TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants") {
    TEST_CASE("lists distinct set_code entries for an exact name in one display set") {
        const std::string json = R"({
          "data":[
            {"name":"Test Goblin",
             "card_sets":[
               {"set_name":"Mega Pack","set_code":"MP21-EN001","set_rarity":"Common"},
               {"set_name":"Mega Pack","set_code":"MP21-EN002","set_rarity":"Rare"}
             ]}
          ]
        })";
        const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
            json, "Mega Pack", "Test Goblin");
        REQUIRE(out.isOk());
        REQUIRE(out.value().size() == 2);
        CHECK(out.value()[0].setNo == "MP21-EN001");
        CHECK(out.value()[1].setNo == "MP21-EN002");
    }

    TEST_CASE("lists multiple rarities for one set_code") {
        const std::string json = R"({
          "data":[
            {"name":"Odd-Eyes Pendulum Dragon",
             "card_sets":[
               {"set_name":"Duelist Alliance","set_code":"DUEA-EN004","set_rarity":"Super Rare"},
               {"set_name":"Duelist Alliance","set_code":"DUEA-EN004","set_rarity":"Ultimate Rare"}
             ]}
          ]
        })";
        const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
            json, "Duelist Alliance", "Odd-Eyes Pendulum Dragon");
        REQUIRE(out.isOk());
        REQUIRE(out.value().size() == 2);
        CHECK(out.value()[0].setNo == "DUEA-EN004");
        CHECK(out.value()[0].rarity == "Super Rare");
        CHECK(out.value()[1].setNo == "DUEA-EN004");
        CHECK(out.value()[1].rarity == "Ultimate Rare");
    }

    TEST_CASE("dedupes identical print pairs") {
        const std::string json = R"({
          "data":[
            {"name":"Mirror Force",
             "card_sets":[
               {"set_name":"Starter Deck","set_code":"SDY-043","set_rarity":"Super Rare"},
               {"set_name":"Starter Deck","set_code":"SDY-043","set_rarity":"Super Rare"}
             ]}
          ]
        })";
        const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
            json, "Starter Deck", "Mirror Force");
        REQUIRE(out.isOk());
        REQUIRE(out.value().size() == 1);
        CHECK(out.value()[0].setNo == "SDY-043");
        CHECK(out.value()[0].rarity == "Super Rare");
    }

    TEST_CASE("malformed JSON surfaces as parse error") {
        const auto out =
            YuGiOhCardPreviewSource::parsePrintVariants("{bad json", "Mega Pack", "X");
        REQUIRE(out.isErr());
        CHECK(out.error().find("YGOPRODeck JSON parse error") != std::string::npos);
    }

    TEST_CASE("maps 25th Anniversary display-set alias to original set name") {
        const std::string json = R"({
          "data":[
            {"name":"Dark Magician",
             "card_sets":[
               {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"}
             ]}
          ]
        })";
        const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
            json, "Legend of Blue Eyes White Dragon (25th Anniversary Edition)", "Dark Magician");
        REQUIRE(out.isOk());
        REQUIRE(out.value().size() == 1);
        CHECK(out.value()[0].setNo == "LOB-005");
    }
}

TEST_SUITE("YuGiOhCardPreviewSource::detectPrintVariants HTTP fallback") {
    TEST_CASE("retries without cardset when the filtered request fails") {
        FailFilteredThenOkHttpClient http;
        http.unfilteredBody = R"({
          "data":[{
            "name":"Test Goblin",
            "card_sets":[
              {"set_name":"Mega Pack","set_code":"MP21-EN001","set_rarity":"Common"}
            ]
          }]
        })";

        YuGiOhCardPreviewSource src{http};
        const auto out = src.detectPrintVariants("Test Goblin", "Mega Pack");
        REQUIRE(out.isOk());
        REQUIRE(out.value().size() == 1);
        CHECK(out.value()[0].setNo == "MP21-EN001");
        REQUIRE(http.calls == 2);
    }

    TEST_CASE("uses original set name in cardset query for 25th alias") {
        FixedHttpClient http;
        http.body = R"({
          "data":[{
            "name":"Dark Magician",
            "card_sets":[
              {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"}
            ]
          }]
        })";

        YuGiOhCardPreviewSource src{http};
        const auto out = src.detectPrintVariants(
            "Dark Magician", "Legend of Blue Eyes White Dragon (25th Anniversary Edition)");
        REQUIRE(out.isOk());
        CHECK(http.lastUrl.find("cardset=Legend%20of%20Blue%20Eyes%20White%20Dragon")
              != std::string::npos);
        CHECK(http.lastUrl.find("25th") == std::string::npos);
    }
}

// Helpers aligned with external fixture `yugioh_same_card_set_variant_tests`
// (same_card_same_set: distinct collector numbers vs distinct rarities).
[[nodiscard]] std::size_t countDistinctSetCodes(const std::vector<AutoDetectedPrint>& v) {
    std::unordered_set<std::string> codes;
    for (const auto& p : v) {
        if (!p.setNo.empty()) codes.insert(p.setNo);
    }
    return codes.size();
}

[[nodiscard]] std::size_t maxRarityVariantsPerCode(const std::vector<AutoDetectedPrint>& v) {
    std::unordered_map<std::string, std::unordered_set<std::string>> byCode;
    for (const auto& p : v) {
        if (p.setNo.empty() || p.rarity.empty()) continue;
        byCode[p.setNo].insert(p.rarity);
    }
    std::size_t mx = 0;
    for (const auto& e : byCode) {
        mx = std::max(mx, e.second.size());
    }
    return mx;
}

TEST_SUITE("YuGiOhCardPreviewSource::parsePrintVariants yugioh_same_card_set_variant_tests") {
    TEST_CASE("S1-001 Armed Dragon LV7 Soul of the Duelist Ultra vs Ultimate") {
        const std::string json = R"({
          "data":[{
            "name":"Armed Dragon LV7",
            "card_sets":[
              {"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultra Rare"},
              {"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultimate Rare"}
            ]
          }]
        })";
        const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
            json, "Soul of the Duelist", "Armed Dragon LV7");
        REQUIRE(out.isOk());
        CHECK(countDistinctSetCodes(out.value()) == 1);
        CHECK(maxRarityVariantsPerCode(out.value()) == 2);
    }

    TEST_CASE("S1-002 Horus LV8 Soul of the Duelist Ultra vs Ultimate") {
        const std::string json = R"({
          "data":[{
            "name":"Horus the Black Flame Dragon LV8",
            "card_sets":[
              {"set_name":"Soul of the Duelist","set_code":"SOD-EN008","set_rarity":"Ultra Rare"},
              {"set_name":"Soul of the Duelist","set_code":"SOD-EN008","set_rarity":"Ultimate Rare"}
            ]
          }]
        })";
        const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
            json, "Soul of the Duelist", "Horus the Black Flame Dragon LV8");
        REQUIRE(out.isOk());
        CHECK(countDistinctSetCodes(out.value()) == 1);
        CHECK(maxRarityVariantsPerCode(out.value()) == 2);
    }

    TEST_CASE("S1-003 Mobius Soul of the Duelist Super vs Ultimate") {
        const std::string json = R"({
          "data":[{
            "name":"Mobius the Frost Monarch",
            "card_sets":[
              {"set_name":"Soul of the Duelist","set_code":"SOD-EN022","set_rarity":"Super Rare"},
              {"set_name":"Soul of the Duelist","set_code":"SOD-EN022","set_rarity":"Ultimate Rare"}
            ]
          }]
        })";
        const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
            json, "Soul of the Duelist", "Mobius the Frost Monarch");
        REQUIRE(out.isOk());
        CHECK(countDistinctSetCodes(out.value()) == 1);
        CHECK(maxRarityVariantsPerCode(out.value()) == 2);
    }

    TEST_CASE("S2-001 Dark Magician Yugi's Legendary Decks three set codes") {
        const std::string json = R"({
          "data":[{
            "name":"Dark Magician",
            "card_sets":[
              {"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENA03","set_rarity":"Common"},
              {"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENB02","set_rarity":"Common"},
              {"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENC09","set_rarity":"Common"}
            ]
          }]
        })";
        const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
            json, "Yugi's Legendary Decks", "Dark Magician");
        REQUIRE(out.isOk());
        CHECK(countDistinctSetCodes(out.value()) == 3);
        CHECK(maxRarityVariantsPerCode(out.value()) == 1);
    }

    TEST_CASE("S2-002 Dark Magician Girl Yugi's Legendary Decks two set codes") {
        const std::string json = R"({
          "data":[{
            "name":"Dark Magician Girl",
            "card_sets":[
              {"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENA04","set_rarity":"Common"},
              {"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENC10","set_rarity":"Common"}
            ]
          }]
        })";
        const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
            json, "Yugi's Legendary Decks", "Dark Magician Girl");
        REQUIRE(out.isOk());
        CHECK(countDistinctSetCodes(out.value()) == 2);
        CHECK(maxRarityVariantsPerCode(out.value()) == 1);
    }

    TEST_CASE("NEG-001 duplicate rarity lines collapse to one variant") {
        const std::string json = R"({
          "data":[{
            "name":"Armed Dragon LV7",
            "card_sets":[
              {"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultra Rare"},
              {"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultra Rare"}
            ]
          }]
        })";
        const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
            json, "Soul of the Duelist", "Armed Dragon LV7");
        REQUIRE(out.isOk());
        CHECK(out.value().size() == 1);
        CHECK(maxRarityVariantsPerCode(out.value()) == 1);
    }

    TEST_CASE("NEG-002 duplicate set codes collapse to one collector slot") {
        const std::string json = R"({
          "data":[{
            "name":"Dark Magician",
            "card_sets":[
              {"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENA03","set_rarity":"Common"},
              {"set_name":"Yugi's Legendary Decks","set_code":"YGLD-ENA03","set_rarity":"Common"}
            ]
          }]
        })";
        const auto out = YuGiOhCardPreviewSource::parsePrintVariants(
            json, "Yugi's Legendary Decks", "Dark Magician");
        REQUIRE(out.isOk());
        CHECK(countDistinctSetCodes(out.value()) == 1);
        CHECK(maxRarityVariantsPerCode(out.value()) == 1);
    }

    TEST_CASE("NEG-003 no cross-product merge when display set matches nothing") {
        const std::string json = R"({
          "data":[{
            "name":"Dark Magician",
            "card_sets":[
              {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"},
              {"set_name":"Starter Deck: Yugi","set_code":"SDY-006","set_rarity":"Ultra Rare"}
            ]
          }]
        })";
        const auto bogus = YuGiOhCardPreviewSource::parsePrintVariants(
            json, "Different Sets", "Dark Magician");
        CHECK(bogus.isErr());

        const auto lob = YuGiOhCardPreviewSource::parsePrintVariants(
            json, "Legend of Blue Eyes White Dragon", "Dark Magician");
        REQUIRE(lob.isOk());
        REQUIRE(lob.value().size() == 1);
        CHECK(lob.value()[0].setNo == "LOB-005");

        const auto sdy = YuGiOhCardPreviewSource::parsePrintVariants(
            json, "Starter Deck: Yugi", "Dark Magician");
        REQUIRE(sdy.isOk());
        REQUIRE(sdy.value().size() == 1);
        CHECK(sdy.value()[0].setNo == "SDY-006");
    }
}

TEST_SUITE("YuGiOhCardPreviewSource::fetchImageUrl") {
    TEST_CASE("supports auto-detect print metadata") {
        FixedHttpClient http;
        YuGiOhCardPreviewSource src{http};
        CHECK(src.supportsAutoDetectPrint());
    }

    TEST_CASE("queries Yugipedia first and uses the per-printing scan when found") {
        // Two same-passcode reprints with genuinely different art (LOB vs
        // SDK Blue-Eyes). Yugipedia hosts both, so we should always pick the
        // SDK one for the SDK input - no leak of the LOB scan into the SDK
        // entry, which was the user-reported regression on YGOPRODeck.
        RoutingHttpClient http;
        http.yugipediaBody = R"({"query":{"pages":{
          "1":{"title":"File:BlueEyesWhiteDragon-SDK-NA-UR-UE.png",
               "imageinfo":[{"url":"https://ms.yugipedia.com/sdk/BlueEyesWhiteDragon-SDK-NA-UR-UE.png"}]}
        }}})";
        // Make the YGOPRODeck fallback obviously wrong so we can assert it
        // isn't being used when Yugipedia returns a hit.
        http.ygoprodeckBody = R"({"data":[{"name":"Blue-Eyes White Dragon",
          "card_images":[{"image_url":"https://example.invalid/wrong.png"}],
          "card_sets":[{"set_code":"SDK-001","set_rarity":"Ultra Rare"}]}]})";

        YuGiOhCardPreviewSource src{http};
        const auto out = src.fetchImageUrl(
            "Blue-Eyes White Dragon", "Starter Deck: Kaiba", "SDK-001||Ultra Rare||UE");
        REQUIRE(out.isOk());
        CHECK(out.value() == "https://ms.yugipedia.com/sdk/BlueEyesWhiteDragon-SDK-NA-UR-UE.png");
        // Verified: only the Yugipedia call was made, not the YGOPRODeck one.
        REQUIRE(http.calls.size() == 1);
        CHECK(http.calls.front().find("yugipedia.com") != std::string::npos);
    }

    TEST_CASE("falls back to YGOPRODeck standard art when Yugipedia has no match") {
        // Empty `pages` -> Yugipedia parser returns an error -> fallback
        // path kicks in. This is the safety net for OCG-only or just-released
        // cards that don't yet have an English scan uploaded to Yugipedia.
        RoutingHttpClient http;
        http.yugipediaBody = R"({"query":{"pages":{
          "-1":{"title":"File:DarkMagician-LOB-EN-UR-UE.png","missing":""}
        }}})";
        http.ygoprodeckBody = R"({"data":[{"name":"Dark Magician",
          "card_images":[{"image_url":"https://images.ygoprodeck.com/std-dm.jpg"}],
          "card_sets":[{"set_code":"LOB-005","set_rarity":"Ultra Rare"}]}]})";

        YuGiOhCardPreviewSource src{http};
        const auto out = src.fetchImageUrl(
            "Dark Magician", "Legend of Blue Eyes White Dragon", "LOB-005||Ultra Rare||UE");
        REQUIRE(out.isOk());
        CHECK(out.value() == "https://images.ygoprodeck.com/std-dm.jpg");
        // Two HTTP calls: Yugipedia first, YGOPRODeck second.
        REQUIRE(http.calls.size() == 2);
        CHECK(http.calls[0].find("yugipedia.com") != std::string::npos);
        CHECK(http.calls[1].find("ygoprodeck.com") != std::string::npos);
    }

    TEST_CASE("falls back when Yugipedia errors out (transient HTTP failure)") {
        // We don't want a Yugipedia outage to leave the user without any
        // preview at all - YGOPRODeck's generic art is acceptable degraded
        // behavior.
        RoutingHttpClient http;
        http.yugipediaOk = false;
        http.ygoprodeckBody = R"({"data":[{"name":"Dark Magician",
          "card_images":[{"image_url":"https://images.ygoprodeck.com/std-dm.jpg"}]}]})";

        YuGiOhCardPreviewSource src{http};
        const auto out = src.fetchImageUrl(
            "Dark Magician", "Legend of Blue Eyes White Dragon", "LOB-005||Ultra Rare||UE");
        REQUIRE(out.isOk());
        CHECK(out.value() == "https://images.ygoprodeck.com/std-dm.jpg");
    }

    TEST_CASE("propagates the YGOPRODeck error as Transient when the fallback also fails") {
        // Both upstreams unreachable -> the overall outcome is transient.
        // CardPreviewService relies on this to avoid negative-caching when
        // the user's connection is offline.
        RoutingHttpClient http;
        http.yugipediaOk = false;
        http.ygoprodeckOk = false;

        YuGiOhCardPreviewSource src{http};
        const auto out = src.fetchImageUrl(
            "Dark Magician", "Legend of Blue Eyes White Dragon", "LOB-005||Ultra Rare||UE");
        REQUIRE(out.isErr());
        CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
    }

    TEST_CASE("classifies as NotFound only when both upstreams answered cleanly with no match") {
        // Yugipedia: clean miss (every candidate "missing").
        // YGOPRODeck: clean miss (empty data array).
        // -> Both upstreams agree -> NotFound, safe to remember.
        RoutingHttpClient http;
        http.yugipediaBody = R"({"query":{"pages":{
          "-1":{"title":"File:Whatever-LOB-EN-UR-UE.png","missing":""}
        }}})";
        http.ygoprodeckBody = R"({"data":[]})";

        YuGiOhCardPreviewSource src{http};
        const auto out = src.fetchImageUrl(
            "No Such Card", "Legend of Blue Eyes White Dragon", "LOB-999||Ultra Rare||UE");
        REQUIRE(out.isErr());
        CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
    }

    TEST_CASE("Yugipedia transient + YGOPRODeck clean-miss is overall Transient") {
        // We can't conclude the card has no image when one upstream
        // couldn't speak. This is the cautious path that keeps the offline
        // user out of the "permanent no-preview" trap.
        RoutingHttpClient http;
        http.yugipediaOk = false;  // network failure
        http.ygoprodeckBody = R"({"data":[]})";  // clean miss

        YuGiOhCardPreviewSource src{http};
        const auto out = src.fetchImageUrl(
            "Possibly Real", "Some Set", "ABC-001||Ultra Rare||UE");
        REQUIRE(out.isErr());
        CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
    }

    TEST_CASE("Yugipedia clean-miss + YGOPRODeck transient is overall Transient") {
        RoutingHttpClient http;
        http.yugipediaBody = R"({"query":{"pages":{
          "-1":{"title":"File:Whatever-LOB-EN-UR-UE.png","missing":""}
        }}})";
        http.ygoprodeckOk = false;

        YuGiOhCardPreviewSource src{http};
        const auto out = src.fetchImageUrl(
            "No Such Card", "Legend of Blue Eyes White Dragon", "LOB-999||Ultra Rare||UE");
        REQUIRE(out.isErr());
        CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
    }

    TEST_CASE("tuple-style setNo parsing trims fields and supports empty set code") {
        FixedHttpClient http;
        http.ok = true;
        http.body = R"({"data":[{"name":"Dark Magician",
          "card_images":[{"image_url":"https://images.ygoprodeck.com/std-dm.jpg"}]}]})";

        YuGiOhCardPreviewSource src{http};
        const auto out = src.fetchImageUrl(
            "Dark Magician", "Legend of Blue Eyes White Dragon", "  || Ultra Rare || 1E ");
        REQUIRE(out.isOk());
        CHECK(out.value() == "https://images.ygoprodeck.com/std-dm.jpg");
        CHECK(http.lastUrl.find("ygoprodeck.com") != std::string::npos);
    }

    TEST_CASE("skips Yugipedia entirely when the set code is missing") {
        // Without a set code we can't construct any candidate filename - go
        // straight to the YGOPRODeck fallback to avoid wasting an HTTP call.
        FixedHttpClient http;
        http.ok = true;
        http.body = R"({"data":[{"name":"Dark Magician",
          "card_images":[{"image_url":"https://images.ygoprodeck.com/std-dm.jpg"}]}]})";

        YuGiOhCardPreviewSource src{http};
        const auto out = src.fetchImageUrl(
            "Dark Magician", "Legend of Blue Eyes White Dragon", "");
        REQUIRE(out.isOk());
        CHECK(out.value() == "https://images.ygoprodeck.com/std-dm.jpg");
        CHECK(http.lastUrl.find("ygoprodeck.com") != std::string::npos);
        CHECK(http.lastUrl.find("yugipedia.com") == std::string::npos);
    }
}

TEST_SUITE("YuGiOhCardPreviewSource::detectFirstPrint") {
    TEST_CASE("returns first variant from filtered request") {
        FixedHttpClient http;
        http.body = R"({
          "data":[
            {"name":"Dark Magician",
             "card_sets":[
               {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"}
             ]}
          ]
        })";
        YuGiOhCardPreviewSource src{http};

        const auto out = src.detectFirstPrint(
            "Dark Magician", "Legend of Blue Eyes White Dragon");
        REQUIRE(out.isOk());
        CHECK(out.value().setNo == "LOB-005");
        CHECK(out.value().rarity == "Ultra Rare");
        CHECK(http.lastUrl.find("cardset=Legend%20of%20Blue%20Eyes%20White%20Dragon")
              != std::string::npos);
    }

    TEST_CASE("propagates unfiltered fallback errors when both requests fail") {
        FixedHttpClient http;
        http.ok = false;
        YuGiOhCardPreviewSource src{http};

        const auto out = src.detectFirstPrint("Any", "Any Set");
        REQUIRE(out.isErr());
        CHECK(out.error() == "offline");
    }
}

TEST_SUITE("YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog") {
    TEST_CASE("matches digits within pack to card name") {
        YuGiOhSetCatalog catalog;
        YuGiOhSetCatalogPack pack;
        pack.setId = "LOB";
        pack.setName = "Legend of Blue Eyes White Dragon";
        pack.cards.push_back(YuGiOhCatalogCard{"LOB-005", "Dark Magician", "Ultra Rare"});
        pack.cards.push_back(YuGiOhCatalogCard{"LOB-EN001", "Blue-Eyes White Dragon", "Ultra Rare"});
        catalog.packs.push_back(std::move(pack));

        const auto byDigits =
            YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "005");
        REQUIRE(byDigits.isOk());
        REQUIRE(byDigits.value().size() == 1);
        CHECK(byDigits.value()[0].name == "Dark Magician");
        CHECK(byDigits.value()[0].setNo == "LOB-005");
        CHECK(byDigits.value()[0].rarity == "Ultra Rare");

        const auto byUnpadded =
            YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "5");
        REQUIRE(byUnpadded.isOk());
        REQUIRE(byUnpadded.value().size() == 1);
        CHECK(byUnpadded.value()[0].name == "Dark Magician");

        const auto byRegionCode =
            YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "LOB-001");
        REQUIRE(byRegionCode.isOk());
        REQUIRE(byRegionCode.value().size() == 1);
        CHECK(byRegionCode.value()[0].name == "Blue-Eyes White Dragon");
    }

    TEST_CASE("digits-only 1 does not match collector 011") {
        YuGiOhSetCatalog catalog;
        YuGiOhSetCatalogPack pack;
        pack.setId = "LOB";
        pack.cards.push_back(YuGiOhCatalogCard{"LOB-005", "Dark Magician"});
        pack.cards.push_back(YuGiOhCatalogCard{"LOB-011", "Hitotsu-Me Giant"});
        catalog.packs.push_back(std::move(pack));

        CHECK(YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "1")
                  .isErr());

        const auto byEleven =
            YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "11");
        REQUIRE(byEleven.isOk());
        REQUIRE(byEleven.value().size() == 1);
        CHECK(byEleven.value()[0].name == "Hitotsu-Me Giant");
    }

    TEST_CASE("unknown pack or number returns error") {
        YuGiOhSetCatalog catalog;
        YuGiOhSetCatalogPack pack;
        pack.setId = "LOB";
        pack.cards.push_back(YuGiOhCatalogCard{"LOB-005", "Dark Magician"});
        catalog.packs.push_back(std::move(pack));

        CHECK(YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "SDK", "001")
                  .isErr());
        CHECK(YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "999")
                  .isErr());
    }

    TEST_CASE("detectVariantsBySetNo falls back to cardset HTTP without catalog") {
        FixedHttpClient http;
        http.body = R"({
          "data":[
            {"name":"Dark Magician",
             "card_sets":[
               {"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-EN005","set_rarity":"Ultra Rare"}
             ]}
          ]
        })";
        YuGiOhCardPreviewSource src{http};
        const auto out = src.detectVariantsBySetNo("Legend of Blue Eyes White Dragon", "5");
        REQUIRE(out.isOk());
        REQUIRE(out.value().size() == 1);
        CHECK(out.value()[0].name == "Dark Magician");
        CHECK(out.value()[0].setNo == "LOB-EN005");
        CHECK(out.value()[0].rarity == "Ultra Rare");
        CHECK(http.lastUrl.find("cardset=") != std::string::npos);
        CHECK(http.lastUrl.find("fname=") == std::string::npos);
    }

    TEST_CASE("cardset reverse keeps distinct rarities for the same set code") {
        const std::string body = R"({
          "data":[
            {"name":"Elemental HERO Bubbleman",
             "card_sets":[
               {"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultra Rare"},
               {"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultimate Rare"}
             ]}
          ]
        })";
        const auto out = YuGiOhCardPreviewSource::detectVariantsBySetNoFromCardset(
            body, "Soul of the Duelist", "15");
        REQUIRE(out.isOk());
        REQUIRE(out.value().size() == 2);
        CHECK(out.value()[0].name == "Elemental HERO Bubbleman");
        CHECK(out.value()[0].rarity == "Ultra Rare");
        CHECK(out.value()[1].rarity == "Ultimate Rare");
    }
}
