#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"

#include "ccm/util/Rfc3986.hpp"

#include <nlohmann/json.hpp>

#include <array>
#include <cctype>
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>

namespace ccm {

namespace {

std::string trim(std::string s) {
    while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
    while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
    return s;
}

std::string toLower(std::string s) {
    for (char& ch : s) {
        ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
    }
    return s;
}

std::string canonicalizeSetNameForAutoDetect(std::string_view setName) {
    std::string canonical = trim(std::string(setName));
    constexpr std::string_view k25thSuffix = " (25th Anniversary Edition)";
    if (canonical.size() > k25thSuffix.size()
        && canonical.ends_with(k25thSuffix)) {
        canonical.erase(canonical.size() - k25thSuffix.size());
        canonical = trim(std::move(canonical));
    }
    return canonical;
}

// Pull the standard art URL out of a YGOPRODeck card object. We deliberately
// always return card_images[0]: when no `cardset=` filter is applied, that
// slot is the original/standard artwork (alt-art passcodes follow), which is
// the closest fallback we have when Yugipedia has no scan for this printing.
std::string imageFromCard(const nlohmann::json& card) {
    if (!card.contains("card_images") || !card.at("card_images").is_array() || card.at("card_images").empty()) {
        return {};
    }
    const auto& first = card.at("card_images").at(0);
    if (first.contains("image_url") && first.at("image_url").is_string()) {
        return first.at("image_url").get<std::string>();
    }
    if (first.contains("image_url_small") && first.at("image_url_small").is_string()) {
        return first.at("image_url_small").get<std::string>();
    }
    if (first.contains("image_url_cropped") && first.at("image_url_cropped").is_string()) {
        return first.at("image_url_cropped").get<std::string>();
    }
    return {};
}

// Split a setNo encoded by the UI as `<setNo>||<rarity>||<edition>` into its
// three positional fields. Any missing trailing field becomes an empty
// string, so older callers that pass just `<setNo>` keep working.
struct ParsedSetNo {
    std::string setNo;
    std::string rarity;
    std::string edition;  // "1E" / "UE" / "" (unknown)
};
ParsedSetNo parseSetNoTuple(std::string_view raw) {
    std::string s(raw);
    ParsedSetNo p;
    const auto a = s.find("||");
    if (a == std::string::npos) {
        p.setNo = trim(std::move(s));
        return p;
    }
    p.setNo = trim(s.substr(0, a));
    std::string rest = s.substr(a + 2);
    const auto b = rest.find("||");
    if (b == std::string::npos) {
        p.rarity = trim(std::move(rest));
        return p;
    }
    p.rarity = trim(rest.substr(0, b));
    p.edition = trim(rest.substr(b + 2));
    return p;
}

}  // namespace

YuGiOhCardPreviewSource::YuGiOhCardPreviewSource(IHttpClient& http) : http_(http) {}

// ============================================================================
// Yugipedia (image-preview path)
// ============================================================================

std::string YuGiOhCardPreviewSource::normalizeName(std::string_view name) {
    // Yugipedia's image policy strips whitespace and a fixed set of
    // punctuation from the displayed card name to produce the file slug.
    // Reference: https://yugipedia.com/wiki/Yugipedia:Image_policy
    std::string out;
    out.reserve(name.size());
    for (unsigned char c : name) {
        if (c <= 0x20) continue;  // whitespace, including non-breaking
        switch (c) {
            case '#': case ',': case '.': case ':': case '\'': case '"':
            case '?': case '!': case '&': case '@': case '%': case '=':
            case '[': case ']': case '<': case '>': case '/': case '\\':
            case '-': case '*': case ';': case '`':
                continue;
            default:
                break;
        }
        out.push_back(static_cast<char>(c));
    }
    return out;
}

std::string YuGiOhCardPreviewSource::rarityCodeFor(std::string_view rarityName) {
    if (const std::string canonical = ygoRarityShortCode(rarityName); !canonical.empty()) {
        return canonical;
    }

    // Compare case-insensitively, ignoring whitespace, against a table of
    // CCM3 dialog values (see ui_wx/src/YuGiOhCardEditDialog.cpp:kRarityOptions)
    // plus a few extras occasionally seen in imported collections. The codes
    // are the ones Yugipedia uses in image filenames.
    std::string lc;
    lc.reserve(rarityName.size());
    for (unsigned char c : rarityName) {
        if (std::isspace(c)) continue;
        lc.push_back(static_cast<char>(std::tolower(c)));
    }
    static const std::array<std::pair<std::string_view, std::string_view>, 32> kTable = {{
        {"common",                       "C"},
        {"shortprint",                   "SP"},
        {"supershortprint",              "SSP"},
        {"normalrare",                   "NR"},
        {"rare",                         "R"},
        {"superrare",                    "SR"},
        {"ultrarare",                    "UR"},
        {"ultimaterare",                 "UtR"},
        {"secretrare",                   "ScR"},
        {"prismaticsecretrare",          "PScR"},
        {"extrasecretrare",              "EScR"},
        {"ultrasecretrare",              "UScR"},
        {"platinumsecretrare",           "PtScR"},
        {"goldsecretrare",               "GScR"},
        {"ghostrare",                    "GR"},
        {"goldrare",                     "GUR"},
        {"premiumgoldrare",              "PGR"},
        {"goldenrare",                   "GUR"},
        {"starfoilrare",                 "SFR"},
        {"shatterfoilrare",              "SHR"},
        {"mosaicrare",                   "MSR"},
        {"parallelrare",                 "PR"},
        {"superparallelrare",            "SPR"},
        {"ultraparallelrare",            "UPR"},
        {"holographicrare",              "HGR"},
        {"starlightrare",                "StR"},
        {"collectorsrare",               "CR"},
        {"prismaticcollectorsrare",      "PColR"},
        {"quartercenturysecretrare",     "QCScR"},
        {"prismaticultimaterare",        "PUtR"},
        {"prismaticredsecretrare",       "PRScR"},
        {"silverletter",                 "SLR"},
    }};
    for (const auto& [k, v] : kTable) {
        if (lc == k) return std::string(v);
    }
    return {};
}

std::string YuGiOhCardPreviewSource::extractSetCode(std::string_view setNo) {
    std::string s(setNo);
    while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
    const auto dash = s.find('-');
    if (dash == std::string::npos) return s;
    return s.substr(0, dash);
}

std::vector<std::string> YuGiOhCardPreviewSource::buildCandidateFilenames(
    std::string_view name,
    std::string_view setCode,
    std::string_view rarityCode,
    bool firstEdition) {
    std::vector<std::string> out;
    const std::string slug = normalizeName(name);
    if (slug.empty() || setCode.empty()) return out;

    // English-only region candidates, in rough usage order: EN is the
    // current default, NA was used on most LOB-era prints, EU/AU show up
    // sporadically. Always English regardless of the card's stored Language.
    static constexpr std::array<std::string_view, 4> kRegions =
        {"EN", "NA", "EU", "AU"};

    // Edition candidates: prefer the printed edition the user has, then
    // try the opposite, then fall back to LE for promo-type prints.
    std::array<std::string_view, 3> editions = {"", "", "LE"};
    if (firstEdition) {
        editions[0] = "1E";
        editions[1] = "UE";
    } else {
        editions[0] = "UE";
        editions[1] = "1E";
    }

    // Two extension variants: Yugipedia has a mix of .png (modern) and .jpg
    // (older uploads) for the same era. Both are common for LOB-era cards.
    static constexpr std::array<std::string_view, 2> kExts = {"png", "jpg"};

    auto pushCombos = [&](std::string_view rarity) {
        for (auto edition : editions) {
            for (auto region : kRegions) {
                for (auto ext : kExts) {
                    std::string fn;
                    fn.reserve(slug.size() + setCode.size() + 16);
                    fn += slug;
                    fn += '-'; fn.append(setCode);
                    fn += '-'; fn.append(region);
                    if (!rarity.empty()) {
                        fn += '-'; fn.append(rarity);
                    }
                    fn += '-'; fn.append(edition);
                    fn += '.'; fn.append(ext);
                    out.push_back(std::move(fn));
                }
            }
        }
    };

    // Primary attempts include the rarity slot. If we don't know the rarity
    // we skip straight to the rarity-less fallback (some sets are uniform
    // rarity and the upload omits the slot).
    if (!rarityCode.empty()) {
        pushCombos(rarityCode);
    }
    pushCombos("");
    return out;
}

std::string YuGiOhCardPreviewSource::buildYugipediaQueryUrl(
    const std::vector<std::string>& filenames) {
    // MediaWiki batch query: `titles=File:A|File:B|File:C` (URL-encoded).
    // One HTTP call returns imageinfo for every page whose file exists; the
    // missing ones come back tagged with `"missing": ""`.
    std::string joined;
    for (size_t i = 0; i < filenames.size(); ++i) {
        if (i > 0) joined += "|";
        joined += "File:";
        joined += filenames[i];
    }
    std::string url =
        "https://yugipedia.com/api.php?action=query&format=json"
        "&prop=imageinfo&iiprop=url&titles=";
    url += rfc3986PercentEncode(joined);
    return url;
}

Result<std::string, PreviewLookupError> YuGiOhCardPreviewSource::parseYugipediaResponse(
    const std::string& body,
    const std::vector<std::string>& filenameOrder) {
    using R = Result<std::string, PreviewLookupError>;
    using K = PreviewLookupError::Kind;
    try {
        const auto j = nlohmann::json::parse(body);
        if (!j.contains("query") || !j.at("query").is_object()) {
            return R::err({K::Transient, "Yugipedia response missing 'query' object."});
        }
        const auto& pages = j.at("query").value("pages", nlohmann::json::object());
        if (!pages.is_object()) {
            return R::err({K::Transient, "Yugipedia response missing 'query.pages'."});
        }

        // Build a name->URL map. MediaWiki returns the title with namespace
        // ("File:...") and may have replaced spaces with underscores; our
        // candidate filenames never contain spaces, so a direct compare on
        // the bit after "File:" is sufficient.
        std::unordered_map<std::string, std::string> resolved;
        resolved.reserve(filenameOrder.size());
        for (auto it = pages.begin(); it != pages.end(); ++it) {
            const auto& page = it.value();
            if (!page.contains("imageinfo")) continue;
            const auto& info = page.at("imageinfo");
            if (!info.is_array() || info.empty()) continue;
            const auto& info0 = info.at(0);
            if (!info0.contains("url") || !info0.at("url").is_string()) continue;

            std::string title = page.value("title", "");
            constexpr std::string_view kPrefix = "File:";
            if (title.rfind(kPrefix, 0) == 0) title.erase(0, kPrefix.size());
            resolved[title] = info0.at("url").get<std::string>();
        }

        // Walk our ordered candidate list and return the first hit. This is
        // how priority works: 1E English first, then UE, then jpg, etc.
        for (const auto& fn : filenameOrder) {
            auto it = resolved.find(fn);
            if (it != resolved.end() && !it->second.empty()) {
                return R::ok(it->second);
            }
        }
        // Every candidate was tagged "missing" => Yugipedia confirmed there
        // is no English scan for this printing. Treat as NotFound; the
        // YGOPRODeck fallback may still surface a generic art.
        return R::err({K::NotFound, "No matching Yugipedia scan found."});
    } catch (const std::exception& e) {
        return R::err({K::Transient,
            std::string("Yugipedia JSON parse error: ") + e.what()});
    }
}

// ============================================================================
// YGOPRODeck (auto-detect path + last-resort fallback)
// ============================================================================

std::string YuGiOhCardPreviewSource::buildSearchUrl(std::string_view name,
                                                    std::string_view setName) {
    std::string url =
        std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=") + rfc3986PercentEncode(name);
    if (!setName.empty()) {
        url += "&cardset=";
        url += rfc3986PercentEncode(setName);
    }
    return url;
}

Result<std::string, PreviewLookupError> YuGiOhCardPreviewSource::parseFallbackImageUrl(
    const std::string& body, std::string_view name) {
    using R = Result<std::string, PreviewLookupError>;
    using K = PreviewLookupError::Kind;
    try {
        const auto j = nlohmann::json::parse(body);
        if (!j.contains("data") || !j.at("data").is_array()) {
            return R::err({K::Transient, "YGOPRODeck response missing 'data' array."});
        }
        const auto& data = j.at("data");
        if (data.empty()) {
            return R::err({K::NotFound, "YGOPRODeck returned no matching cards."});
        }
        const std::string wantedNameLower = toLower(trim(std::string(name)));

        // Prefer the exact-name match: the fuzzy `fname=` search can mix in
        // sibling cards (Dark Magician + Dark Magician Girl), and we don't
        // want to land on a sibling's standard art.
        for (const auto& card : data) {
            const std::string cardName = trim(card.value("name", ""));
            if (!wantedNameLower.empty() && toLower(cardName) == wantedNameLower) {
                const std::string image = imageFromCard(card);
                if (!image.empty()) return R::ok(image);
            }
        }
        // Failing that, take whatever YGOPRODeck ranked first.
        const std::string image = imageFromCard(data.at(0));
        if (!image.empty()) {
            return R::ok(image);
        }
        return R::err({K::NotFound, "Card has no image variants."});
    } catch (const std::exception& e) {
        return R::err({K::Transient,
            std::string("YGOPRODeck JSON parse error: ") + e.what()});
    }
}

Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::parsePrintVariants(
    const std::string& body,
    std::string_view preferredSetName,
    std::string_view wantedCardName) {
    using R = Result<std::vector<AutoDetectedPrint>>;
    try {
        const auto j = nlohmann::json::parse(body);
        if (!j.contains("data") || !j.at("data").is_array() || j.at("data").empty()) {
            return R::err("YGOPRODeck returned no matching cards.");
        }
        const std::string wantedSet = canonicalizeSetNameForAutoDetect(preferredSetName);
        const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));

        std::vector<AutoDetectedPrint> collected;
        auto pushPrint = [&collected](const nlohmann::json& print) {
            AutoDetectedPrint out;
            out.setNo = trim(print.value("set_code", ""));
            out.rarity = trim(print.value("set_rarity", ""));
            if (out.setNo.empty() && out.rarity.empty()) return;
            collected.push_back(std::move(out));
        };

        for (const auto& card : j.at("data")) {
            if (!wantedNameLower.empty()) {
                const std::string cardName = trim(card.value("name", ""));
                if (toLower(cardName) != wantedNameLower) continue;
            }
            if (!card.contains("card_sets") || !card.at("card_sets").is_array()) continue;
            for (const auto& print : card.at("card_sets")) {
                const std::string setName = trim(print.value("set_name", ""));
                if (!wantedSet.empty() && setName != wantedSet) continue;
                pushPrint(print);
            }
        }

        // Mirror parseFirstPrint fallback: if nothing matched `wantedSet`, take
        // every print from `data[0]` without filtering by set_name.
        //
        // When the caller supplied an exact card name (edit-dialog variant
        // listing), combining unrelated `card_sets[]` rows after a non-empty
        // display-set filter missed would falsely imply multiple printings
        // "in one set" (different real-world products share the same card).
        if (collected.empty()) {
            if (!wantedNameLower.empty() && !wantedSet.empty()) {
                return R::err("Could not auto-detect set print metadata.");
            }
            const auto& firstCard = j.at("data").at(0);
            if (!wantedNameLower.empty()) {
                const std::string cardName = trim(firstCard.value("name", ""));
                if (toLower(cardName) != wantedNameLower) {
                    return R::err("Could not auto-detect set print metadata.");
                }
            }
            if (firstCard.contains("card_sets") && firstCard.at("card_sets").is_array()) {
                for (const auto& print : firstCard.at("card_sets")) {
                    pushPrint(print);
                }
            }
        }

        if (collected.empty()) {
            return R::err("Could not auto-detect set print metadata.");
        }

        std::vector<AutoDetectedPrint> deduped;
        deduped.reserve(collected.size());
        std::unordered_set<std::string> seen;
        seen.reserve(collected.size() * 2);
        for (auto& p : collected) {
            const std::string key = p.setNo + '\0' + p.rarity;
            if (seen.insert(key).second) deduped.push_back(std::move(p));
        }
        return R::ok(std::move(deduped));
    } catch (const std::exception& e) {
        return R::err(std::string("YGOPRODeck JSON parse error: ") + e.what());
    }
}

Result<AutoDetectedPrint> YuGiOhCardPreviewSource::parseFirstPrint(
    const std::string& body, std::string_view preferredSetName) {
    auto list = parsePrintVariants(body, preferredSetName, "");
    if (!list || list.value().empty()) {
        if (!list) return Result<AutoDetectedPrint>::err(list.error());
        return Result<AutoDetectedPrint>::err("Could not auto-detect set print metadata.");
    }
    return Result<AutoDetectedPrint>::ok(list.value().front());
}

// ============================================================================
// Public ICardPreviewSource API
// ============================================================================

Result<std::string, PreviewLookupError>
YuGiOhCardPreviewSource::fetchImageUrl(std::string_view name,
                                       std::string_view /*setId*/,
                                       std::string_view setNo) {
    using R = Result<std::string, PreviewLookupError>;
    using K = PreviewLookupError::Kind;
    const ParsedSetNo p = parseSetNoTuple(setNo);
    const std::string setCode = extractSetCode(p.setNo);
    const std::string rarityCode = rarityCodeFor(p.rarity);
    const bool firstEdition = (p.edition == "1E");

    // The overall classification needs the worst outcome across the two
    // upstreams: NotFound only when *both* answered cleanly with no match,
    // Transient as soon as either one couldn't speak. We track Yugipedia's
    // outcome here and combine it with YGOPRODeck's below.
    bool yugipediaSawTransient = false;
    PreviewLookupError yugipediaErr{K::NotFound, "Yugipedia not consulted."};

    // Step 1: Yugipedia per-printing scan. Build a batch of plausible English
    // filenames and ask MediaWiki for them all in one call. This is the only
    // source we know of that distinguishes art between same-passcode reprints
    // (LOB Blue-Eyes vs SDK Blue-Eyes, etc.).
    //
    // No usable set code (or empty candidate list) is treated as an
    // "inapplicable" Yugipedia step rather than a failure - we don't want a
    // legitimate metadata gap to taint the final classification as transient.
    if (!setCode.empty()) {
        const auto candidates = buildCandidateFilenames(
            name, setCode, rarityCode, firstEdition);
        if (!candidates.empty()) {
            const std::string url = buildYugipediaQueryUrl(candidates);
            auto resp = http_.get(url);
            if (!resp) {
                yugipediaSawTransient = true;
                yugipediaErr = {K::Transient, resp.error()};
            } else {
                auto parsed = parseYugipediaResponse(resp.value(), candidates);
                if (parsed) return parsed;
                yugipediaErr = std::move(parsed).error();
                if (yugipediaErr.kind == K::Transient) yugipediaSawTransient = true;
            }
        }
    }

    // Step 2: YGOPRODeck standard-art fallback. Only used when Yugipedia has
    // no scan we can match (newly-added cards, OCG-only cards without an
    // English release, transient Yugipedia errors). Always unfiltered, so
    // card_images[0] is the original artwork rather than an alt-art reprint.
    const std::string fallbackUrl = buildSearchUrl(name, "");
    auto fallback = http_.get(fallbackUrl);
    if (!fallback) {
        // YGOPRODeck failed at the network layer => the overall lookup is
        // transient regardless of what Yugipedia did. Surface YGOPRODeck's
        // error string because it's the most recent failure.
        return R::err({K::Transient, fallback.error()});
    }
    auto parsed = parseFallbackImageUrl(fallback.value(), name);
    if (parsed) return parsed;

    // Both upstreams answered. If *either* one was transient, the overall
    // outcome is transient (we can't conclude the record has no image).
    PreviewLookupError fallbackErr = std::move(parsed).error();
    if (yugipediaSawTransient || fallbackErr.kind == K::Transient) {
        return R::err({K::Transient,
            yugipediaSawTransient ? yugipediaErr.message : fallbackErr.message});
    }
    // Otherwise both confirmed "no image" => safe to remember.
    return R::err({K::NotFound, fallbackErr.message});
}

Result<AutoDetectedPrint> YuGiOhCardPreviewSource::detectFirstPrint(std::string_view name,
                                                                    std::string_view setId) {
    auto list = detectPrintVariants(name, setId);
    if (!list || list.value().empty()) {
        if (!list) return Result<AutoDetectedPrint>::err(list.error());
        return Result<AutoDetectedPrint>::err("Could not auto-detect set print metadata.");
    }
    return Result<AutoDetectedPrint>::ok(list.value().front());
}

Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::detectPrintVariants(
    std::string_view name,
    std::string_view setId) {
    using R = Result<std::vector<AutoDetectedPrint>>;
    const std::string canonicalSetName = canonicalizeSetNameForAutoDetect(setId);
    const std::string url = buildSearchUrl(name, canonicalSetName);
    auto resp = http_.get(url);
    if (resp) {
        return parsePrintVariants(resp.value(), canonicalSetName, name);
    }
    const std::string fallbackUrl = buildSearchUrl(name, "");
    auto fallback = http_.get(fallbackUrl);
    if (!fallback) return R::err(fallback.error());
    return parsePrintVariants(fallback.value(), canonicalSetName, name);
}

Result<std::vector<AutoDetectedPrint>>
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(
    const YuGiOhSetCatalog& catalog,
    std::string_view setId,
    std::string_view setNo) {
    using R = Result<std::vector<AutoDetectedPrint>>;
    const std::string packId = std::string(trimAsciiSpaces(setId));
    if (packId.empty()) return R::err("Select a set first.");

    const std::string rawNo = std::string(trimAsciiSpaces(setNo));
    if (rawNo.empty()) return R::err("Card number is empty.");

    const std::string wantDigits =
        ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(rawNo));
    if (wantDigits.empty()) return R::err("Card number is empty.");

    const YuGiOhSetCatalogPack* pack = catalog.findPack(packId);
    if (pack == nullptr) {
        // Allow callers to pass the display set name (HTTP fallback path).
        for (const auto& candidate : catalog.packs) {
            if (candidate.setName == packId) {
                pack = &candidate;
                break;
            }
        }
    }
    if (pack == nullptr) {
        return R::err("Set not found in offline catalog. Run Sets → Update Yu-Gi-Oh! first.");
    }

    std::vector<AutoDetectedPrint> out;
    std::unordered_set<std::string> seenNames;
    for (const auto& card : pack->cards) {
        if (!ygoCollectorDigitsEqual(card.setNo, rawNo)) continue;
        if (card.name.empty()) continue;
        if (!seenNames.insert(card.name).second) continue;
        AutoDetectedPrint print;
        print.name = card.name;
        print.setNo = card.setNo;
        print.rarity = card.rarity;
        out.push_back(std::move(print));
    }
    if (out.empty()) {
        return R::err("Could not auto-detect card name from set number.");
    }
    return R::ok(std::move(out));
}

std::string YuGiOhCardPreviewSource::buildCardsetOnlyUrl(std::string_view setName) {
    return std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?cardset=") +
           rfc3986PercentEncode(setName);
}

Result<std::vector<AutoDetectedPrint>>
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCardset(
    const std::string& body,
    std::string_view preferredSetName,
    std::string_view setNo) {
    using R = Result<std::vector<AutoDetectedPrint>>;
    const std::string wantDigits =
        ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(setNo));
    if (wantDigits.empty()) return R::err("Card number is empty.");

    try {
        const auto j = nlohmann::json::parse(body);
        if (!j.contains("data") || !j.at("data").is_array()) {
            return R::err("YGOPRODeck response missing 'data' array.");
        }
        const std::string preferredLower = toLower(trim(std::string(preferredSetName)));

        std::vector<AutoDetectedPrint> out;
        std::unordered_set<std::string> seen;
        for (const auto& card : j.at("data")) {
            const std::string cardName = trim(card.value("name", ""));
            if (cardName.empty()) continue;
            if (!card.contains("card_sets") || !card.at("card_sets").is_array()) continue;
            for (const auto& printing : card.at("card_sets")) {
                const std::string setName = trim(printing.value("set_name", ""));
                const std::string setCode = trim(printing.value("set_code", ""));
                if (setCode.empty()) continue;
                if (ygoLikelyEuropeanRegionalSetCode(setCode)) continue;
                if (!preferredLower.empty() && toLower(setName) != preferredLower) continue;
                if (!ygoCollectorDigitsEqual(setCode, setNo)) continue;
                AutoDetectedPrint print;
                print.name = cardName;
                print.setNo = setCode;
                print.rarity = trim(printing.value("set_rarity", ""));
                const std::string key = print.name + '\0' + print.setNo + '\0' + print.rarity;
                if (!seen.insert(key).second) continue;
                out.push_back(std::move(print));
            }
        }
        if (out.empty()) {
            return R::err("Could not auto-detect card name from set number.");
        }
        return R::ok(std::move(out));
    } catch (const std::exception& e) {
        return R::err(std::string("YGOPRODeck JSON parse error: ") + e.what());
    }
}

Result<AutoDetectedPrint> YuGiOhCardPreviewSource::detectBySetNo(std::string_view setId,
                                                                 std::string_view setNo) {
    auto list = detectVariantsBySetNo(setId, setNo);
    if (!list) return Result<AutoDetectedPrint>::err(list.error());
    if (list.value().empty()) {
        return Result<AutoDetectedPrint>::err(
            "Could not auto-detect card name from set number.");
    }
    return Result<AutoDetectedPrint>::ok(list.value().front());
}

Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::detectVariantsBySetNo(
    std::string_view setId,
    std::string_view setNo) {
    using R = Result<std::vector<AutoDetectedPrint>>;
    const std::string setKey = std::string(trimAsciiSpaces(setId));
    if (setKey.empty()) return R::err("Select a set first.");
    if (ygoCollectorDigitsFromInput(setNo).empty()) {
        return R::err("Card number is empty.");
    }

    // 1) Offline catalog (preferred — fast once cached).
    if (catalogStore_ != nullptr) {
        if (!catalogCache_) {
            auto loaded = catalogStore_->load();
            if (loaded) catalogCache_ = std::move(loaded).value();
        }
        if (catalogCache_ && !catalogCache_->empty()) {
            auto fromCatalog =
                detectVariantsBySetNoFromCatalog(*catalogCache_, setKey, setNo);

            // Prefer YGOPRODeck when reachable so rarity (and multi-rarity
            // variants) come through — the offline catalog may predate the
            // rarity field or only keep one rarity per printing slot.
            const YuGiOhSetCatalogPack* pack = catalogCache_->findPack(setKey);
            std::string setName = setKey;
            if (pack != nullptr) {
                setName = pack->setName;
            } else {
                for (const auto& candidate : catalogCache_->packs) {
                    if (candidate.setName == setKey) {
                        setName = candidate.setName;
                        break;
                    }
                }
            }
            if (!setName.empty()) {
                auto resp = http_.get(buildCardsetOnlyUrl(setName));
                if (resp) {
                    auto fromHttp =
                        detectVariantsBySetNoFromCardset(resp.value(), setName, setNo);
                    if (fromHttp) return fromHttp;
                }
            }

            if (fromCatalog) return fromCatalog;
            // Prefer catalog miss text when HTTP also missed / was unreachable.
            return fromCatalog;
        }
    }

    // 2) No catalog: treat setKey as display set name and query YGOPRODeck.
    auto resp = http_.get(buildCardsetOnlyUrl(setKey));
    if (!resp) {
        return R::err(
            "Set catalog missing and YGOPRODeck lookup failed. "
            "Run Sets → Update Yu-Gi-Oh! or check your network.");
    }
    return detectVariantsBySetNoFromCardset(resp.value(), setKey, setNo);
}

}  // namespace ccm
