#include <doctest/doctest.h>

#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include "ccm/ports/IPreviewByteCache.hpp"
#include "ccm/services/CardPreviewService.hpp"

#include <filesystem>
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>

using namespace ccm;

namespace {

class FakeSource final : public ICardPreviewSource {
public:
    std::string url = "https://example.com/preview.jpg";
    bool ok = true;
    // The kind of error returned when ok == false. Defaults to Transient
    // because the historical tests assumed "errors don't get cached"; the
    // negative-cache tests flip this to NotFound explicitly.
    PreviewLookupError::Kind errKind = PreviewLookupError::Kind::Transient;
    std::string err = "boom";
    int calls{0};

    // Capture inputs so tests can assert routing.
    std::string lastName;
    std::string lastSetId;
    std::string lastSetNo;
    std::string detectLastName;
    std::string detectLastSetId;
    std::string detectLastSetNo;
    AutoDetectedPrint detectedPrint{"LOB-001", "Ultra Rare"};
    bool allowAutoDetect{true};

    Result<std::string, PreviewLookupError>
        fetchImageUrl(std::string_view name,
                      std::string_view setId,
                      std::string_view setNo) override {
        ++calls;
        lastName  = std::string(name);
        lastSetId = std::string(setId);
        lastSetNo = std::string(setNo);
        if (ok) {
            return Result<std::string, PreviewLookupError>::ok(url);
        }
        return Result<std::string, PreviewLookupError>::err({errKind, err});
    }

    Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
                                               std::string_view setId) override {
        detectLastName = std::string(name);
        detectLastSetId = std::string(setId);
        return Result<AutoDetectedPrint>::ok(detectedPrint);
    }

    Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
                                                               std::string_view setId) override {
        detectLastName  = std::string(name);
        detectLastSetId = std::string(setId);
        std::vector<AutoDetectedPrint> v;
        v.push_back(detectedPrint);
        return Result<std::vector<AutoDetectedPrint>>::ok(std::move(v));
    }

    Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
                                            std::string_view setNo) override {
        detectLastSetId = std::string(setId);
        detectLastSetNo = std::string(setNo);
        return Result<AutoDetectedPrint>::ok(detectedPrint);
    }

    Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
        std::string_view setId,
        std::string_view setNo) override {
        detectLastSetId = std::string(setId);
        detectLastSetNo = std::string(setNo);
        std::vector<AutoDetectedPrint> v;
        v.push_back(detectedPrint);
        return Result<std::vector<AutoDetectedPrint>>::ok(std::move(v));
    }

    [[nodiscard]] bool supportsAutoDetectPrint() const noexcept override {
        return allowAutoDetect;
    }
};

class FixedHttpClient final : public IHttpClient {
public:
    std::string lastUrl;
    std::string body;
    bool ok = true;
    std::string err = "offline";
    int calls{0};

    Result<std::string> get(std::string_view url) override {
        ++calls;
        lastUrl = std::string(url);
        return ok ? Result<std::string>::ok(body)
                  : Result<std::string>::err(err);
    }
};

// In-memory persistent cache stand-in. Real implementation lives in
// LocalPreviewByteCache (covered by local_preview_byte_cache_tests.cpp); the
// fake here lets us pin down CardPreviewService's wiring without dragging in
// the filesystem.
class InMemoryByteCache final : public IPreviewByteCache {
public:
    struct Entry {
        bool        negative{false};
        std::string payload;
    };
    std::unordered_map<std::string, Entry> entries;
    int loadCalls{0};
    int storeCalls{0};
    int storeNegativeCalls{0};

    [[nodiscard]] LoadResult load(std::string_view key) override {
        ++loadCalls;
        auto it = entries.find(std::string(key));
        if (it == entries.end()) return {HitKind::Miss, {}};
        if (it->second.negative) return {HitKind::NegativeHit, {}};
        return {HitKind::Hit, it->second.payload};
    }
    void store(std::string_view key, const std::string& payload) override {
        ++storeCalls;
        entries[std::string(key)] = Entry{false, payload};
    }
    void storeNegative(std::string_view key) override {
        ++storeNegativeCalls;
        entries[std::string(key)] = Entry{true, {}};
    }
};

class AlwaysNegativeUrlCache final : public IPreviewByteCache {
public:
    [[nodiscard]] LoadResult load(std::string_view key) override {
        if (!key.empty() && key.front() == 'u') {
            return {HitKind::NegativeHit, {}};
        }
        return {HitKind::Miss, {}};
    }
    void store(std::string_view, const std::string&) override {}
    void storeNegative(std::string_view) override {}
};

class MemoryFileSystem final : public IFileSystem {
public:
    std::unordered_map<std::string, std::string> files;

    [[nodiscard]] bool exists(const std::filesystem::path& p) const override {
        return files.contains(p.generic_string());
    }
    [[nodiscard]] bool isDirectory(const std::filesystem::path&) const override {
        return false;
    }
    Result<void> ensureDirectory(const std::filesystem::path&) override {
        return Result<void>::ok();
    }
    Result<std::string> readText(const std::filesystem::path& p) override {
        auto it = files.find(p.generic_string());
        if (it == files.end()) {
            return Result<std::string>::err("Unable to open " + p.generic_string());
        }
        return Result<std::string>::ok(it->second);
    }
    Result<void> writeText(const std::filesystem::path& p, std::string_view contents) override {
        files[p.generic_string()] = std::string(contents);
        return Result<void>::ok();
    }
    Result<void> copyFile(const std::filesystem::path& from,
                          const std::filesystem::path& to,
                          bool) override {
        auto it = files.find(from.generic_string());
        if (it == files.end()) {
            return Result<void>::err("missing source");
        }
        files[to.generic_string()] = it->second;
        return Result<void>::ok();
    }
    Result<void> remove(const std::filesystem::path& p) override {
        files.erase(p.generic_string());
        return Result<void>::ok();
    }
    Result<std::vector<std::filesystem::path>> listDirectory(
        const std::filesystem::path&) override {
        return Result<std::vector<std::filesystem::path>>::ok({});
    }
};

// Minimal IGameModule fake that exposes a configurable preview source.
class FakeGameModule final : public IGameModule {
public:
    Game gameId = Game::Magic;
    ICardPreviewSource* preview = nullptr;

    [[nodiscard]] Game        id() const noexcept override { return gameId; }
    [[nodiscard]] std::string dirName()     const override { return "fake"; }
    [[nodiscard]] std::string displayName() const override { return "Fake"; }

    ISetSource& setSource() override {
        // Tests in this file never call setSource(); a never-returned helper
        // would clutter the fake. Throwing keeps misuse loud.
        throw std::logic_error("FakeGameModule::setSource() not used in this test");
    }
    ICardPreviewSource* cardPreviewSource() noexcept override { return preview; }
};

}  // namespace

TEST_SUITE("CardPreviewService::fetchPreviewBytes") {
    TEST_CASE("happy path: routes through registered module then http GET") {
        FakeSource source;
        source.url = "https://example.com/img.png";
        FakeGameModule module;
        module.gameId = Game::Magic;
        module.preview = &source;

        FixedHttpClient http;
        http.body = std::string("\x89PNG\r\n\x1a\n", 8);  // arbitrary binary

        CardPreviewService svc{http};
        svc.registerModule(module);

        const auto out = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
        REQUIRE(out.isOk());
        CHECK(out.value() == http.body);
        CHECK(http.lastUrl == "https://example.com/img.png");
        CHECK(source.lastName  == "Lightning Bolt");
        CHECK(source.lastSetId == "lea");
        CHECK(source.lastSetNo.empty());
    }

    TEST_CASE("module returning nullptr preview source is skipped silently") {
        FakeGameModule module;
        module.gameId = Game::Pokemon;
        module.preview = nullptr;

        FixedHttpClient http;
        CardPreviewService svc{http};
        svc.registerModule(module);  // no-op

        const auto out = svc.fetchPreviewBytes(Game::Pokemon, "Pikachu", "sv3", "1");
        CHECK(out.isErr());
        CHECK(out.error().find("No preview source registered") != std::string::npos);
    }

    TEST_CASE("unregistered game returns an explicit error") {
        FixedHttpClient http;
        CardPreviewService svc{http};
        const auto out = svc.fetchPreviewBytes(Game::Pokemon, "Pikachu", "sv3", "1");
        CHECK(out.isErr());
        CHECK(out.error().find("No preview source registered") != std::string::npos);
    }

    TEST_CASE("source error propagates and http is not called") {
        FakeSource source;
        source.ok = false;
        source.err = "scryfall 404";
        FakeGameModule module;
        module.gameId = Game::Magic;
        module.preview = &source;

        FixedHttpClient http;
        http.lastUrl = "<unset>";

        CardPreviewService svc{http};
        svc.registerModule(module);

        const auto out = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
        CHECK(out.isErr());
        CHECK(out.error() == "scryfall 404");
        CHECK(http.lastUrl == "<unset>");
    }

    TEST_CASE("http GET error propagates") {
        FakeSource source;
        FakeGameModule module;
        module.gameId = Game::Magic;
        module.preview = &source;

        FixedHttpClient http;
        http.ok = false;
        http.err = "net down";

        CardPreviewService svc{http};
        svc.registerModule(module);

        const auto out = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
        CHECK(out.isErr());
        CHECK(out.error() == "net down");
    }

    TEST_CASE("asset: preview loads bytes from configured asset root") {
        FakeSource source;
        source.url = "asset:pokemon_jp_classic/TamamushiCG/016.jpg";
        FakeGameModule module;
        module.gameId = Game::JapanesePokemon;
        module.preview = &source;

        FixedHttpClient http;
        MemoryFileSystem fs;
        fs.files["assets/pokemon_jp_classic/TamamushiCG/016.jpg"] = "JPEG-bytes";

        CardPreviewService svc{http, nullptr, &fs, "assets"};
        svc.registerModule(module);

        const auto out = svc.fetchPreviewBytes(Game::JapanesePokemon, "Erika", "TamamushiCG", "016");
        REQUIRE(out.isOk());
        CHECK(out.value() == "JPEG-bytes");
        CHECK(http.calls == 0);
    }
}

TEST_SUITE("CardPreviewService caching") {
    TEST_CASE("repeat fetchPreviewBytes for the same key serves from cache") {
        // Re-selecting the same row in the table is the hot path: the
        // (game, name, setId, setNo) tuple uniquely identifies a printing,
        // and the resolved image URL is a deterministic function of those
        // inputs - so we can safely cache the bytes and skip HTTP entirely
        // on repeat. Each cache hit spares us two HTTPS round trips
        // (Yugipedia API + image GET) plus a TLS handshake on a cold pool.
        FakeSource source;
        source.url = "https://example.com/img.png";
        FakeGameModule module;
        module.gameId = Game::Magic;
        module.preview = &source;

        FixedHttpClient http;
        http.body = "PNG-bytes";

        CardPreviewService svc{http};
        svc.registerModule(module);

        const auto first = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
        REQUIRE(first.isOk());
        CHECK(first.value() == "PNG-bytes");
        CHECK(http.calls == 1);

        // Mutate the source's response to prove the second call doesn't go
        // through the source either: only the cache should be consulted.
        source.url = "https://example.com/CHANGED.png";
        http.body = "DIFFERENT-bytes";

        const auto second = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
        REQUIRE(second.isOk());
        CHECK(second.value() == "PNG-bytes");
        CHECK(http.calls == 1);  // no new GET issued
    }

    TEST_CASE("different cards share the same source but get separate cache slots") {
        FakeSource source;
        FakeGameModule module;
        module.gameId = Game::Magic;
        module.preview = &source;

        FixedHttpClient http;

        CardPreviewService svc{http};
        svc.registerModule(module);

        source.url = "https://example.com/a.png";
        http.body = "A-bytes";
        const auto a = svc.fetchPreviewBytes(Game::Magic, "Card A", "lea", "");
        REQUIRE(a.isOk());
        CHECK(a.value() == "A-bytes");

        source.url = "https://example.com/b.png";
        http.body = "B-bytes";
        const auto b = svc.fetchPreviewBytes(Game::Magic, "Card B", "lea", "");
        REQUIRE(b.isOk());
        CHECK(b.value() == "B-bytes");

        CHECK(http.calls == 2);

        // Re-fetching A returns the originally cached payload, not the most
        // recent http.body; this pins down per-card cache scoping.
        http.body = "stale";
        const auto aAgain = svc.fetchPreviewBytes(Game::Magic, "Card A", "lea", "");
        REQUIRE(aAgain.isOk());
        CHECK(aAgain.value() == "A-bytes");
        CHECK(http.calls == 2);
    }

    TEST_CASE("source errors are not cached so a transient failure can recover") {
        // If the per-game source returns an error we should not poison the
        // cache - the next selection must be free to retry, otherwise a
        // single network blip would permanently disable previews for that
        // card until the user restarts the app.
        FakeSource source;
        FakeGameModule module;
        module.gameId = Game::Magic;
        module.preview = &source;

        FixedHttpClient http;
        http.body = "PNG-bytes";

        CardPreviewService svc{http};
        svc.registerModule(module);

        source.ok = false;
        source.err = "transient 503";
        const auto firstErr = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
        CHECK(firstErr.isErr());
        CHECK(http.calls == 0);  // source short-circuited before HTTP

        source.ok = true;
        source.url = "https://example.com/x.png";
        const auto recovered = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
        REQUIRE(recovered.isOk());
        CHECK(recovered.value() == "PNG-bytes");
        CHECK(http.calls == 1);
    }

    TEST_CASE("memory-only caching works when no persistent cache is configured") {
        FakeSource source;
        source.url = "https://example.com/img.png";
        FakeGameModule module;
        module.gameId = Game::Magic;
        module.preview = &source;

        FixedHttpClient http;
        http.body = "PNG-bytes";

        CardPreviewService svc{http, nullptr};
        svc.registerModule(module);

        REQUIRE(svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "").isOk());
        http.body = "OTHER";
        const auto second = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
        REQUIRE(second.isOk());
        CHECK(second.value() == "PNG-bytes");
        CHECK(http.calls == 1);
    }

    TEST_CASE("registerModule replaces the preview source for the same game") {
        FakeSource firstSource;
        firstSource.url = "https://example.com/first.png";
        FakeGameModule firstModule;
        firstModule.gameId = Game::Magic;
        firstModule.preview = &firstSource;

        FakeSource secondSource;
        secondSource.url = "https://example.com/second.png";
        FakeGameModule secondModule;
        secondModule.gameId = Game::Magic;
        secondModule.preview = &secondSource;

        FixedHttpClient http;
        http.body = "SECOND";

        CardPreviewService svc{http};
        svc.registerModule(firstModule);
        svc.registerModule(secondModule);

        const auto out = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
        REQUIRE(out.isOk());
        CHECK(out.value() == "SECOND");
        CHECK(http.lastUrl == "https://example.com/second.png");
        CHECK(firstSource.calls == 0);
        CHECK(secondSource.calls == 1);
    }

    TEST_CASE("NotFound without persistent cache still negative-caches in memory") {
        FakeSource source;
        source.ok = false;
        source.errKind = PreviewLookupError::Kind::NotFound;
        source.err = "not found";
        FakeGameModule module;
        module.gameId = Game::Magic;
        module.preview = &source;

        FixedHttpClient http;
        CardPreviewService svc{http, nullptr};
        svc.registerModule(module);

        REQUIRE(svc.fetchPreviewBytes(Game::Magic, "X", "abc", "").isErr());
        CHECK(source.calls == 1);

        const auto second = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
        REQUIRE(second.isErr());
        CHECK(second.error() == "No preview available for this card.");
        CHECK(source.calls == 1);
        CHECK(http.calls == 0);
    }

    TEST_CASE("HTTP success writes through to the persistent cache") {
        // The persistent tier is fire-and-forget on the way down (HTTP -> disk)
        // and consulted on the way up (cache miss -> disk -> HTTP). This first
        // sub-case exercises the write-through path.
        FakeSource source;
        source.url = "https://example.com/img.png";
        FakeGameModule module;
        module.gameId = Game::Magic;
        module.preview = &source;

        FixedHttpClient http;
        http.body = "PNG-bytes";

        InMemoryByteCache disk;
        CardPreviewService svc{http, &disk};
        svc.registerModule(module);

        const auto out = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
        REQUIRE(out.isOk());
        CHECK(disk.storeCalls == 1);
        CHECK(disk.entries.size() == 1);
        // Key shape is service-internal so we don't assert it directly; only
        // that *something* identifying this card was persisted with the right
        // payload.
        bool foundPayload = false;
        for (const auto& [k, e] : disk.entries) {
            if (!e.negative && e.payload == "PNG-bytes") foundPayload = true;
        }
        CHECK(foundPayload);
    }

    TEST_CASE("warm restart: a fresh service instance serves from disk without HTTP") {
        // Simulates an app restart by constructing two CardPreviewService
        // instances over the same persistent cache. The second instance
        // must serve the previously-fetched bytes from disk - no source
        // call, no HTTP call - which is the whole point of the persistent
        // tier.
        FakeSource source;
        source.url = "https://example.com/img.png";
        FakeGameModule module;
        module.gameId = Game::YuGiOh;
        module.preview = &source;

        FixedHttpClient http;
        http.body = "DM-bytes";

        InMemoryByteCache disk;

        // Run 1: cold start, normal fetch path warms the persistent cache.
        {
            CardPreviewService svc{http, &disk};
            svc.registerModule(module);
            const auto out = svc.fetchPreviewBytes(
                Game::YuGiOh, "Dark Magician", "Starter Deck: Yugi", "SDY-006||Ultra Rare||UE");
            REQUIRE(out.isOk());
        }
        REQUIRE(http.calls == 1);
        REQUIRE(disk.entries.size() == 1);

        // Run 2: pretend the app restarted. Make HTTP and the source both
        // return obviously-wrong data so any unexpected fall-through to
        // them shows up loudly in the assertion.
        http.body = "UNEXPECTED";
        source.url = "https://example.com/UNEXPECTED.png";
        {
            CardPreviewService svc{http, &disk};
            svc.registerModule(module);
            const auto warm = svc.fetchPreviewBytes(
                Game::YuGiOh, "Dark Magician", "Starter Deck: Yugi", "SDY-006||Ultra Rare||UE");
            REQUIRE(warm.isOk());
            CHECK(warm.value() == "DM-bytes");
        }
        // No second HTTP request - disk tier covered it.
        CHECK(http.calls == 1);
        CHECK(disk.loadCalls >= 1);
    }

    TEST_CASE("disk hit promotes into the in-memory tier so subsequent calls skip even disk I/O") {
        FakeSource source;
        source.url = "https://example.com/img.png";
        FakeGameModule module;
        module.gameId = Game::Magic;
        module.preview = &source;

        FixedHttpClient http;
        http.body = "X-bytes";

        InMemoryByteCache disk;
        // Pre-seed the disk cache with a value the service has never fetched
        // this session. The first call should pull it off disk and copy it
        // into memory; the second call should be served from memory without
        // touching the disk cache at all.
        // We can't compose the key from outside, so we go through the
        // service once to learn the key shape via storeCalls.
        {
            CardPreviewService svc{http, &disk};
            svc.registerModule(module);
            (void)svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
        }
        REQUIRE(http.calls == 1);
        REQUIRE(disk.entries.size() == 1);
        const auto storedKey = disk.entries.begin()->first;

        // Clear the in-memory tier by constructing a fresh service. Disk
        // entry stays.
        CardPreviewService svc{http, &disk};
        svc.registerModule(module);

        const int loadsBefore = disk.loadCalls;
        // Force HTTP to fail loudly so we can prove neither call hits it.
        http.ok = false;

        const auto first = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
        REQUIRE(first.isOk());
        CHECK(first.value() == "X-bytes");
        CHECK(disk.loadCalls == loadsBefore + 1);

        const auto second = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
        REQUIRE(second.isOk());
        CHECK(second.value() == "X-bytes");
        // No additional disk hit - in-memory tier served it.
        CHECK(disk.loadCalls == loadsBefore + 1);
        // Sanity: only the original storedKey ever made it to disk.
        CHECK(disk.entries.count(storedKey) == 1);
    }

    TEST_CASE("transient source errors do not write through to the persistent cache") {
        // A transient failure (HTTP 5xx, network drop, parse error) must
        // never get cached - if it did, a single bad request would
        // silently break this card's preview until the user restarts the
        // app or edits the record.
        FakeSource source;
        source.ok = false;
        source.errKind = PreviewLookupError::Kind::Transient;
        source.err = "scryfall 503";
        FakeGameModule module;
        module.gameId = Game::Magic;
        module.preview = &source;

        FixedHttpClient http;
        InMemoryByteCache disk;

        CardPreviewService svc{http, &disk};
        svc.registerModule(module);

        const auto out = svc.fetchPreviewBytes(Game::Magic, "X", "abc", "");
        CHECK(out.isErr());
        CHECK(disk.storeCalls == 0);
        CHECK(disk.storeNegativeCalls == 0);
        CHECK(disk.entries.empty());
    }

    TEST_CASE("NotFound source errors are negative-cached and short-circuit subsequent calls") {
        // A NotFound result means the upstream cleanly answered "no image
        // for this record". The next selection of the *same* record must
        // return immediately without invoking the source again - that's
        // the whole point of negative caching, and the user-visible win
        // (no spinner, no per-click HTTP) on every selection of a card
        // whose printing has no scan upstream.
        FakeSource source;
        source.ok = false;
        source.errKind = PreviewLookupError::Kind::NotFound;
        source.err = "no scan exists";
        FakeGameModule module;
        module.gameId = Game::Magic;
        module.preview = &source;

        FixedHttpClient http;
        InMemoryByteCache disk;

        CardPreviewService svc{http, &disk};
        svc.registerModule(module);

        const auto first = svc.fetchPreviewBytes(Game::Magic, "Bad Card", "abc", "");
        CHECK(first.isErr());
        CHECK(source.calls == 1);
        CHECK(disk.storeCalls == 0);
        CHECK(disk.storeNegativeCalls == 1);

        // Second call: the source must NOT be consulted again. The
        // in-memory negative entry covers it.
        const auto second = svc.fetchPreviewBytes(Game::Magic, "Bad Card", "abc", "");
        CHECK(second.isErr());
        CHECK(source.calls == 1);  // still 1 - cache short-circuited
        CHECK(http.calls == 0);
    }

    TEST_CASE("editing a lookup-relevant field invalidates the negative entry automatically") {
        // The cache key is (game, name, setId, setNo). The user fixing a
        // typo in the card's name (or any other lookup-relevant field)
        // changes the key, so the previously-cached "no image" verdict no
        // longer matches and a fresh resolution attempt runs.
        FakeSource source;
        source.ok = false;
        source.errKind = PreviewLookupError::Kind::NotFound;
        source.err = "no scan exists";
        FakeGameModule module;
        module.gameId = Game::Magic;
        module.preview = &source;

        FixedHttpClient http;
        InMemoryByteCache disk;

        CardPreviewService svc{http, &disk};
        svc.registerModule(module);

        // First lookup: the typo name produces a clean NotFound -> negative cache.
        REQUIRE(svc.fetchPreviewBytes(Game::Magic, "Lighting Bolt", "lea", "").isErr());
        REQUIRE(source.calls == 1);

        // Now flip the source to "found" and ask for the corrected name.
        source.ok = true;
        source.url = "https://example.com/lb.png";
        http.body = "LB-bytes";

        const auto fixed = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", "");
        REQUIRE(fixed.isOk());
        CHECK(fixed.value() == "LB-bytes");
        CHECK(source.calls == 2);

        // And the original (typo) record stays negative-cached.
        source.ok = false;  // belt-and-braces: prove the typo path doesn't re-call the source
        const auto stillNegative =
            svc.fetchPreviewBytes(Game::Magic, "Lighting Bolt", "lea", "");
        CHECK(stillNegative.isErr());
        CHECK(source.calls == 2);  // typo path never re-resolved
    }

    TEST_CASE("warm restart honors a previously-stored negative entry without HTTP") {
        // Persistent negative caching is the strongest motivation for the
        // disk tier carrying negatives at all: a card with no upstream
        // image stays "instant card-back" across app restarts, instead of
        // re-paying the round-trip to learn the same thing every launch.
        FakeSource source;
        source.ok = false;
        source.errKind = PreviewLookupError::Kind::NotFound;
        source.err = "no scan exists";
        FakeGameModule module;
        module.gameId = Game::Magic;
        module.preview = &source;

        FixedHttpClient http;
        InMemoryByteCache disk;

        // Run 1: negative outcome, persisted.
        {
            CardPreviewService svc{http, &disk};
            svc.registerModule(module);
            REQUIRE(svc.fetchPreviewBytes(Game::Magic, "Z", "set", "").isErr());
        }
        REQUIRE(disk.storeNegativeCalls == 1);
        const int sourceCallsAfterRun1 = source.calls;

        // Run 2: fresh service over the same disk fake. Source must not be
        // consulted at all - the negative entry on disk short-circuits it.
        {
            CardPreviewService svc{http, &disk};
            svc.registerModule(module);
            const auto warm = svc.fetchPreviewBytes(Game::Magic, "Z", "set", "");
            CHECK(warm.isErr());
        }
        CHECK(source.calls == sourceCallsAfterRun1);
    }

    TEST_CASE("a later positive result for the same key replaces the negative entry") {
        // If the upstream eventually grows a scan for a card that was
        // previously NotFound, the very next successful lookup must
        // overwrite the negative entry so it doesn't keep haunting the
        // user. Realistic scenario: Yugipedia uploads a scan that didn't
        // exist before; the user clicks the row again and we now serve
        // the real image. (In production the cache key may also change
        // due to a record edit, but that's a separate path; here we test
        // the same-key recovery story.)
        FakeSource source;
        FakeGameModule module;
        module.gameId = Game::Magic;
        module.preview = &source;

        FixedHttpClient http;
        InMemoryByteCache disk;

        CardPreviewService svc{http, &disk};
        svc.registerModule(module);

        // First call: NotFound.
        source.ok = false;
        source.errKind = PreviewLookupError::Kind::NotFound;
        source.err = "no scan yet";
        REQUIRE(svc.fetchPreviewBytes(Game::Magic, "Card", "set", "").isErr());

        // Imitate the upstream growing a scan, AND simulate the user
        // retrying. To bypass the negative cache without a record edit,
        // construct a fresh service: this models the next-launch case
        // where the user reopens the app and the image now exists. (Within
        // a single session, only an edit to the record clears the cache,
        // which is the correct UX - we don't want a network round-trip on
        // every click for cards we already know have no upstream image.)
        source.ok = true;
        source.url = "https://example.com/late.png";
        http.body = "LATE-bytes";

        // Drop the disk negative manually to simulate a scenario where
        // the cache was cleared (e.g. the user manually wiped the cache
        // directory). In production code paths, an edit to the record is
        // the standard invalidation; both routes converge on the same
        // expected behavior: a fresh successful lookup.
        disk.entries.clear();

        CardPreviewService svc2{http, &disk};
        svc2.registerModule(module);
        const auto out = svc2.fetchPreviewBytes(Game::Magic, "Card", "set", "");
        REQUIRE(out.isOk());
        CHECK(out.value() == "LATE-bytes");
    }

    TEST_CASE("fetchImageBytesByUrl caches by URL too (fallback card-back)") {
        // The fallback card-back image is fetched via fetchImageBytesByUrl
        // for every card without a preview; caching by URL makes the second
        // fallback effectively free.
        FixedHttpClient http;
        http.body = "card-back-bytes";
        CardPreviewService svc{http};

        const auto first = svc.fetchImageBytesByUrl("https://cdn.example/back.png");
        REQUIRE(first.isOk());
        CHECK(http.calls == 1);

        http.body = "stale";
        const auto second = svc.fetchImageBytesByUrl("https://cdn.example/back.png");
        REQUIRE(second.isOk());
        CHECK(second.value() == "card-back-bytes");
        CHECK(http.calls == 1);
    }

    TEST_CASE("empty HTTP body is rejected and not cached") {
        FakeSource source;
        source.url = "https://example.com/empty.png";
        FakeGameModule module;
        module.gameId = Game::Magic;
        module.preview = &source;

        FixedHttpClient http;
        http.body = "";

        CardPreviewService svc{http};
        svc.registerModule(module);

        const auto first = svc.fetchPreviewBytes(Game::Magic, "Any", "set", "1");
        CHECK(first.isErr());
        CHECK(first.error().find("Empty response body") != std::string::npos);
        CHECK(http.calls == 1);

        const auto second = svc.fetchPreviewBytes(Game::Magic, "Any", "set", "1");
        CHECK(second.isErr());
        CHECK(http.calls == 2);
    }

    TEST_CASE("url negative entry on disk is treated as miss and refetched") {
        FixedHttpClient http;
        http.body = "card-back";
        AlwaysNegativeUrlCache disk;
        CardPreviewService svc{http, &disk};

        const auto out = svc.fetchImageBytesByUrl("https://cdn.example/back.png");
        REQUIRE(out.isOk());
        CHECK(out.value() == "card-back");
        CHECK(http.calls == 1);
    }

    TEST_CASE("fetchImageBytesByUrl propagates HTTP errors when uncached") {
        FixedHttpClient http;
        http.ok = false;
        http.err = "url fetch failed";
        CardPreviewService svc{http};

        const auto out = svc.fetchImageBytesByUrl("https://cdn.example/back.png");
        REQUIRE(out.isErr());
        CHECK(out.error() == "url fetch failed");
    }

    TEST_CASE("fetchImageBytesByUrl serves from persistent cache hit without HTTP") {
        FixedHttpClient http;
        http.body = "warm-card-back";
        InMemoryByteCache disk;

        // Seed persistent cache via first service instance.
        {
            CardPreviewService seed{http, &disk};
            const auto seeded = seed.fetchImageBytesByUrl("https://cdn.example/back.png");
            REQUIRE(seeded.isOk());
            CHECK(seeded.value() == "warm-card-back");
        }
        REQUIRE(http.calls == 1);

        // Fresh service instance: force HTTP failure and ensure disk hit is used.
        CardPreviewService warm{http, &disk};
        http.ok = false;
        const auto warmHit = warm.fetchImageBytesByUrl("https://cdn.example/back.png");
        REQUIRE(warmHit.isOk());
        CHECK(warmHit.value() == "warm-card-back");
        CHECK(http.calls == 1);
    }

    TEST_CASE("in-memory LRU evicts oldest entry after exceeding capacity") {
        FakeSource source;
        FakeGameModule module;
        module.gameId = Game::Magic;
        module.preview = &source;

        FixedHttpClient http;
        http.body = "x";

        CardPreviewService svc{http};
        svc.registerModule(module);

        const auto cap = CardPreviewService::kCacheCapacity;
        for (std::size_t i = 0; i < cap + 1; ++i) {
            const std::string name = std::string("LRU-") + std::to_string(i);
            REQUIRE(svc.fetchPreviewBytes(Game::Magic, name, "lea", "").isOk());
        }
        REQUIRE(http.calls == cap + 1);

        REQUIRE(svc.fetchPreviewBytes(Game::Magic, "LRU-0", "lea", "").isOk());
        CHECK(http.calls == cap + 2);
    }
}

TEST_SUITE("CardPreviewService::detectFirstPrint") {
    TEST_CASE("routes auto-detect to registered source") {
        FakeSource source;
        source.detectedPrint = {"LOB-005", "Secret Rare"};
        FakeGameModule module;
        module.gameId = Game::YuGiOh;
        module.preview = &source;

        FixedHttpClient http;
        CardPreviewService svc{http};
        svc.registerModule(module);

        const auto out = svc.detectFirstPrint(Game::YuGiOh, "Dark Magician", "Legend of Blue Eyes White Dragon");
        REQUIRE(out.isOk());
        CHECK(out.value().setNo == "LOB-005");
        CHECK(out.value().rarity == "Secret Rare");
        CHECK(source.detectLastName == "Dark Magician");
        CHECK(source.detectLastSetId == "Legend of Blue Eyes White Dragon");
    }

    TEST_CASE("returns explicit error when game does not enable auto-detect") {
        FakeSource source;
        source.allowAutoDetect = false;
        FakeGameModule module;
        module.gameId = Game::Magic;
        module.preview = &source;

        FixedHttpClient http;
        CardPreviewService svc{http};
        svc.registerModule(module);

        const auto out = svc.detectFirstPrint(Game::Magic, "Any", "Any Set");
        CHECK(out.isErr());
        CHECK(out.error().find("not enabled") != std::string::npos);
    }

    TEST_CASE("unregistered game returns explicit error") {
        FixedHttpClient http;
        CardPreviewService svc{http};
        const auto out =
            svc.detectFirstPrint(Game::YuGiOh, "Dark Magician", "LOB");
        CHECK(out.isErr());
        CHECK(out.error().find("No preview source registered") !=
              std::string::npos);
    }
}

TEST_SUITE("CardPreviewService::detectPrintVariants") {
    TEST_CASE("routes variant listing to registered source") {
        FakeSource source;
        source.detectedPrint = {"LOB-005", "Secret Rare"};
        FakeGameModule module;
        module.gameId = Game::YuGiOh;
        module.preview = &source;

        FixedHttpClient http;
        CardPreviewService svc{http};
        svc.registerModule(module);

        const auto out = svc.detectPrintVariants(Game::YuGiOh, "Dark Magician",
                                                 "Legend of Blue Eyes White Dragon");
        REQUIRE(out.isOk());
        REQUIRE(out.value().size() == 1);
        CHECK(out.value()[0].setNo == "LOB-005");
        CHECK(out.value()[0].rarity == "Secret Rare");
    }

    TEST_CASE("detectPrintVariants returns error when game does not enable auto-detect") {
        FakeSource source;
        source.allowAutoDetect = false;
        FakeGameModule module;
        module.gameId = Game::Magic;
        module.preview = &source;

        FixedHttpClient http;
        CardPreviewService svc{http};
        svc.registerModule(module);

        const auto out = svc.detectPrintVariants(Game::Magic, "Any", "Any Set");
        CHECK(out.isErr());
        CHECK(out.error().find("not enabled") != std::string::npos);
    }

    TEST_CASE("unregistered game returns explicit error") {
        FixedHttpClient http;
        CardPreviewService svc{http};
        const auto out =
            svc.detectPrintVariants(Game::YuGiOh, "Dark Magician", "LOB");
        CHECK(out.isErr());
        CHECK(out.error().find("No preview source registered") !=
              std::string::npos);
    }
}

TEST_SUITE("CardPreviewService::detectVariantsBySetNo") {
    TEST_CASE("routes set-scoped reverse lookup to registered source") {
        FakeSource source;
        source.detectedPrint = AutoDetectedPrint{"4", "Rare", "Pikachu"};
        FakeGameModule module;
        module.gameId = Game::Pokemon;
        module.preview = &source;

        FixedHttpClient http;
        CardPreviewService svc{http};
        svc.registerModule(module);

        const auto out = svc.detectVariantsBySetNo(Game::Pokemon, "base1", "4");
        REQUIRE(out.isOk());
        REQUIRE(out.value().size() == 1);
        CHECK(out.value()[0].name == "Pikachu");
        CHECK(out.value()[0].setNo == "4");
        CHECK(source.detectLastSetId == "base1");
        CHECK(source.detectLastSetNo == "4");
    }

    TEST_CASE("returns error when game does not enable auto-detect") {
        FakeSource source;
        source.allowAutoDetect = false;
        FakeGameModule module;
        module.gameId = Game::Magic;
        module.preview = &source;

        FixedHttpClient http;
        CardPreviewService svc{http};
        svc.registerModule(module);

        const auto out = svc.detectVariantsBySetNo(Game::Magic, "lea", "1");
        CHECK(out.isErr());
        CHECK(out.error().find("not enabled") != std::string::npos);
    }
}
