#include "ccm/services/CardPreviewService.hpp"

#include <filesystem>
#include <string>
#include <utility>
#include <vector>

namespace ccm {

namespace {

// Compose a stable cache key from the four lookup coordinates. Using NUL as
// a separator keeps the key unambiguous even if a card's name happens to
// contain `|` or other punctuation.
std::string makePreviewKey(Game game,
                           std::string_view name,
                           std::string_view setId,
                           std::string_view setNo) {
    std::string k;
    k.reserve(2 + name.size() + setId.size() + setNo.size() + 3);
    k.push_back('p');
    k.push_back(static_cast<char>(static_cast<int>(game)));
    k.push_back('\0');
    k.append(name);
    k.push_back('\0');
    k.append(setId);
    k.push_back('\0');
    k.append(setNo);
    return k;
}

std::string makeUrlKey(std::string_view url) {
    std::string k;
    k.reserve(url.size() + 1);
    k.push_back('u');
    k.append(url);
    return k;
}

constexpr std::string_view kAssetScheme = "asset:";

}  // namespace

CardPreviewService::CardPreviewService(IHttpClient& http,
                                       IPreviewByteCache* persistentCache,
                                       IFileSystem* fs,
                                       std::filesystem::path assetRoot)
    : http_(http),
      persistentCache_(persistentCache),
      fs_(fs),
      assetRoot_(std::move(assetRoot)) {}

void CardPreviewService::registerModule(IGameModule& module) {
    if (auto* src = module.cardPreviewSource(); src != nullptr) {
        sources_[module.id()] = src;
    }
}

CardPreviewService::CacheLookupKind CardPreviewService::cacheLookup(
    const std::string& key, std::string& outPayload) {
    std::lock_guard<std::mutex> lock(cacheMutex_);
    auto it = cacheIndex_.find(key);
    if (it == cacheIndex_.end()) {
        outPayload.clear();
        return CacheLookupKind::Miss;
    }
    // Move-to-front to mark as most-recently-used.
    cacheList_.splice(cacheList_.begin(), cacheList_, it->second);
    if (it->second->negative) {
        outPayload.clear();
        return CacheLookupKind::NegativeHit;
    }
    outPayload = it->second->payload;
    return CacheLookupKind::Hit;
}

void CardPreviewService::cacheStore(const std::string& key, std::string payload) {
    if (payload.empty()) return;
    std::lock_guard<std::mutex> lock(cacheMutex_);
    auto it = cacheIndex_.find(key);
    if (it != cacheIndex_.end()) {
        // Overwrite existing entry (positive or negative) and bump it to
        // the front. Replacing a negative entry is the "we got a real
        // image after a previous NotFound" path - rare but valid.
        it->second->payload  = std::move(payload);
        it->second->negative = false;
        cacheList_.splice(cacheList_.begin(), cacheList_, it->second);
        return;
    }
    cacheList_.push_front({key, std::move(payload), /*negative=*/false});
    cacheIndex_.emplace(key, cacheList_.begin());
    while (cacheList_.size() > kCacheCapacity) {
        cacheIndex_.erase(cacheList_.back().key);
        cacheList_.pop_back();
    }
}

void CardPreviewService::cacheStoreNegative(const std::string& key) {
    std::lock_guard<std::mutex> lock(cacheMutex_);
    auto it = cacheIndex_.find(key);
    if (it != cacheIndex_.end()) {
        it->second->payload.clear();
        it->second->negative = true;
        cacheList_.splice(cacheList_.begin(), cacheList_, it->second);
        return;
    }
    cacheList_.push_front({key, std::string{}, /*negative=*/true});
    cacheIndex_.emplace(key, cacheList_.begin());
    while (cacheList_.size() > kCacheCapacity) {
        cacheIndex_.erase(cacheList_.back().key);
        cacheList_.pop_back();
    }
}

Result<std::string> CardPreviewService::fetchAndCache(const std::string& cacheKey,
                                                      std::string_view url) {
    auto bytes = http_.get(url);
    if (!bytes) return Result<std::string>::err(bytes.error());
    std::string payload = std::move(bytes).value();
    if (payload.empty()) {
        return Result<std::string>::err("Empty response body from " + std::string(url));
    }
    cacheStore(cacheKey, payload);
    // Best-effort persist to disk so the next app launch starts warm.
    // The persistent tier is fire-and-forget: any I/O error is swallowed
    // by the adapter, the in-memory tier still holds the bytes.
    if (persistentCache_ != nullptr) {
        persistentCache_->store(cacheKey, payload);
    }
    return Result<std::string>::ok(std::move(payload));
}

Result<std::string, PreviewLookupError> CardPreviewService::fetchAssetAndCache(
    const std::string& cacheKey,
    std::string_view assetUrl) {
    using R = Result<std::string, PreviewLookupError>;
    using K = PreviewLookupError::Kind;

    if (fs_ == nullptr || assetRoot_.empty()) {
        return R::err({K::Transient, "Asset preview path is not configured."});
    }
    if (!assetUrl.starts_with(kAssetScheme)) {
        return R::err({K::Transient, "Asset preview URL is missing the asset: prefix."});
    }
    std::filesystem::path rel(std::string(assetUrl.substr(kAssetScheme.size())));
    const auto fullPath = assetRoot_ / rel;
    auto bytes = fs_->readText(fullPath);
    if (!bytes) {
        return R::err({K::NotFound,
                       "Bundled preview asset not found: " + fullPath.generic_string()});
    }
    std::string payload = std::move(bytes).value();
    if (payload.empty()) {
        return R::err({K::NotFound,
                       "Bundled preview asset is empty: " + fullPath.generic_string()});
    }
    cacheStore(cacheKey, payload);
    if (persistentCache_ != nullptr) {
        persistentCache_->store(cacheKey, payload);
    }
    return R::ok(std::move(payload));
}

Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
                                                          std::string_view name,
                                                          std::string_view setId,
                                                          std::string_view setNo) {
    auto it = sources_.find(game);
    if (it == sources_.end() || it->second == nullptr) {
        return Result<std::string>::err("No preview source registered for this game.");
    }

    // Cache check before any HTTP call. The (game, name, setId, setNo) tuple
    // uniquely identifies a printing for our purposes - the resolved image
    // URL is always a deterministic function of those four inputs, and any
    // edit to a lookup-relevant field changes the key automatically.
    const std::string key = makePreviewKey(game, name, setId, setNo);
    std::string cached;
    switch (cacheLookup(key, cached)) {
        case CacheLookupKind::Hit:
            return Result<std::string>::ok(std::move(cached));
        case CacheLookupKind::NegativeHit:
            return Result<std::string>::err("No preview available for this card.");
        case CacheLookupKind::Miss:
            break;
    }
    // Disk-backed second tier: previews persisted by an earlier app run
    // get promoted into the in-memory LRU on first access this session, so
    // subsequent re-selections stay fast without re-touching the network.
    // Negative entries on disk are likewise promoted - the user already
    // knows from a previous session that this record has no upstream image.
    if (persistentCache_ != nullptr) {
        const auto disk = persistentCache_->load(key);
        switch (disk.kind) {
            case IPreviewByteCache::HitKind::Hit:
                cacheStore(key, disk.payload);
                return Result<std::string>::ok(disk.payload);
            case IPreviewByteCache::HitKind::NegativeHit:
                cacheStoreNegative(key);
                return Result<std::string>::err("No preview available for this card.");
            case IPreviewByteCache::HitKind::Miss:
                break;
        }
    }

    auto url = it->second->fetchImageUrl(name, setId, setNo);
    if (!url) {
        // The two error kinds split here:
        //   - NotFound: upstream answered cleanly that this record has no
        //     image. Persist the verdict so we don't keep retrying.
        //   - Transient: network/HTTP/parse failure. Surface the error
        //     unchanged and DO NOT cache anything; the next selection
        //     retries from scratch.
        const auto err = std::move(url).error();
        if (err.kind == PreviewLookupError::Kind::NotFound) {
            cacheStoreNegative(key);
            if (persistentCache_ != nullptr) persistentCache_->storeNegative(key);
        }
        return Result<std::string>::err(err.message);
    }
    if (url.value().starts_with(kAssetScheme)) {
        auto asset = fetchAssetAndCache(key, url.value());
        if (!asset) {
            const auto err = std::move(asset).error();
            if (err.kind == PreviewLookupError::Kind::NotFound) {
                cacheStoreNegative(key);
                if (persistentCache_ != nullptr) persistentCache_->storeNegative(key);
            }
            return Result<std::string>::err(err.message);
        }
        return Result<std::string>::ok(std::move(asset).value());
    }
    return fetchAndCache(key, url.value());
}

Result<AutoDetectedPrint> CardPreviewService::detectFirstPrint(Game game,
                                                               std::string_view name,
                                                               std::string_view setId) {
    auto it = sources_.find(game);
    if (it == sources_.end() || it->second == nullptr) {
        return Result<AutoDetectedPrint>::err("No preview source registered for this game.");
    }
    if (!it->second->supportsAutoDetectPrint()) {
        return Result<AutoDetectedPrint>::err("Auto-detect not enabled for this game.");
    }
    return it->second->detectFirstPrint(name, setId);
}

Result<std::vector<AutoDetectedPrint>> CardPreviewService::detectPrintVariants(
    Game game,
    std::string_view name,
    std::string_view setId) {
    auto it = sources_.find(game);
    if (it == sources_.end() || it->second == nullptr) {
        return Result<std::vector<AutoDetectedPrint>>::err(
            "No preview source registered for this game.");
    }
    if (!it->second->supportsAutoDetectPrint()) {
        return Result<std::vector<AutoDetectedPrint>>::err(
            "Auto-detect not enabled for this game.");
    }
    return it->second->detectPrintVariants(name, setId);
}

Result<AutoDetectedPrint> CardPreviewService::detectBySetNo(Game game,
                                                            std::string_view setId,
                                                            std::string_view setNo) {
    auto it = sources_.find(game);
    if (it == sources_.end() || it->second == nullptr) {
        return Result<AutoDetectedPrint>::err("No preview source registered for this game.");
    }
    if (!it->second->supportsAutoDetectPrint()) {
        return Result<AutoDetectedPrint>::err("Auto-detect not enabled for this game.");
    }
    return it->second->detectBySetNo(setId, setNo);
}

Result<std::vector<AutoDetectedPrint>> CardPreviewService::detectVariantsBySetNo(
    Game game,
    std::string_view setId,
    std::string_view setNo) {
    auto it = sources_.find(game);
    if (it == sources_.end() || it->second == nullptr) {
        return Result<std::vector<AutoDetectedPrint>>::err(
            "No preview source registered for this game.");
    }
    if (!it->second->supportsAutoDetectPrint()) {
        return Result<std::vector<AutoDetectedPrint>>::err(
            "Auto-detect not enabled for this game.");
    }
    return it->second->detectVariantsBySetNo(setId, setNo);
}

Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view url) {
    // The by-URL path is used for fixed per-game card-back fallback images.
    // A failure there is always transient (the URL itself is constant), so
    // there is no negative-cache analogue to worry about; we just look up
    // and, if needed, fetch+store.
    const std::string key = makeUrlKey(url);
    std::string cached;
    const auto mem = cacheLookup(key, cached);
    if (mem == CacheLookupKind::Hit) {
        return Result<std::string>::ok(std::move(cached));
    }
    // Miss, or a spurious negative under a URL key (never written by normal
    // code) — both continue to disk / network.
    if (persistentCache_ != nullptr) {
        const auto disk = persistentCache_->load(key);
        if (disk.kind == IPreviewByteCache::HitKind::Hit) {
            cacheStore(key, disk.payload);
            return Result<std::string>::ok(disk.payload);
        }
    }
    if (url.starts_with(kAssetScheme)) {
        auto asset = fetchAssetAndCache(key, url);
        if (!asset) return Result<std::string>::err(asset.error().message);
        return Result<std::string>::ok(std::move(asset).value());
    }
    return fetchAndCache(key, url);
}

}  // namespace ccm
