package dev.photosync.ui.image;

import dev.photosync.core.provider.RemoteAsset;
import dev.photosync.core.provider.ThumbnailSize;
import dev.photosync.core.thumbnail.ThumbHash;
import dev.photosync.core.thumbnail.ThumbImage;
import dev.photosync.core.thumbnail.ThumbnailLoader;
import dev.photosync.mcapi.render.TextureHandle;
import dev.photosync.mcapi.render.TextureSink;
import lombok.extern.slf4j.Slf4j;

import java.io.IOException;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;

/**
 * The textures behind the timeline grid: what is on the GPU, what is on its way,
 * and what has to go.
 *
 * <p>An asset shows something on the very first frame it appears, because the
 * timeline page already carried a ThumbHash -- twenty-odd bytes that decode to a
 * blurred 32x32 -- and swaps in the real thumbnail when it arrives. That is the
 * difference between a grid that fills in and a grid of grey boxes.
 *
 * <p>Everything here runs on the render thread. The fetching does not: this
 * class only ever <em>polls</em> {@link ThumbnailLoader}'s futures, so a slow
 * server stalls a tile rather than a frame.
 *
 * <p>Eviction is least-recently-drawn, bounded by the player's cache setting,
 * and skips anything drawn in the current frame. Without that last rule a grid
 * with more visible tiles than the cache holds would evict a texture it is about
 * to draw and re-request it forever.
 */
@Slf4j
public final class ThumbnailCache implements AutoCloseable {

    /** What to draw for one asset, and whether it is the real thing yet. */
    public record Thumbnail(TextureHandle texture, boolean placeholder) {
    }

    private final TextureSink textures;
    private final ThumbnailLoader loader;
    private final ThumbnailSize size;

    /** Access-ordered, so its iteration order is the eviction order. */
    private final LinkedHashMap<String, Entry> entries = new LinkedHashMap<>(64, 0.75f, true);

    private int capacity;
    private long frame;

    public ThumbnailCache(TextureSink textures, ThumbnailLoader loader, ThumbnailSize size, int capacity) {
        this.textures = textures;
        this.loader = loader;
        this.size = size;
        this.capacity = Math.max(1, capacity);
    }

    /**
     * Follows the player's setting without discarding what is already loaded.
     *
     * <p>Only one is imposed here, because the caller is the one who knows what
     * it is caching: the grid's floor comes from its config setting, and the
     * detail view genuinely wants a capacity of two. Nothing thrashes at a small
     * capacity anyway -- {@link #endFrame()} refuses to evict what was drawn.
     */
    public void capacity(int value) {
        this.capacity = Math.max(1, value);
    }

    /** Call once at the top of a frame, before any {@link #of} in that frame. */
    public void beginFrame() {
        frame++;
    }

    /**
     * What to draw for {@code asset} right now, starting a fetch if this is the
     * first time it has been asked for.
     *
     * <p>Empty means there is genuinely nothing yet -- no ThumbHash and no
     * thumbnail -- which the grid draws as a plain tile.
     */
    public Optional<Thumbnail> of(RemoteAsset asset) {
        Entry entry = entries.computeIfAbsent(asset.id(), id -> new Entry(asset));
        entry.touched = frame;
        entry.poll();
        return entry.thumbnail();
    }

    /** Whether this asset's thumbnail failed outright, so the grid can mark it. */
    public boolean failed(String assetId) {
        Entry entry = entries.get(assetId);
        return entry != null && entry.failed;
    }

    /** Call at the end of a frame, once every visible tile has been asked for. */
    public void endFrame() {
        if (entries.size() <= capacity) {
            return;
        }
        Iterator<Map.Entry<String, Entry>> stale = entries.entrySet().iterator();
        while (entries.size() > capacity && stale.hasNext()) {
            Entry entry = stale.next().getValue();
            // Never evict something drawn this frame: on a screen with more
            // tiles than the cache holds, that would thrash rather than cache.
            if (entry.touched == frame) {
                continue;
            }
            entry.close();
            stale.remove();
        }
    }

    /** Drops everything -- when the album changes, or the screen closes. */
    public void clear() {
        entries.values().forEach(Entry::close);
        entries.clear();
    }

    @Override
    public void close() {
        clear();
    }

    // -----------------------------------------------------------------------

    private final class Entry implements AutoCloseable {

        private final RemoteAsset asset;

        private TextureHandle texture;
        private boolean real;
        private boolean failed;
        private CompletableFuture<byte[]> pending;
        private long touched;

        private Entry(RemoteAsset asset) {
            this.asset = asset;
            this.texture = decodeHash(asset);
        }

        private Optional<Thumbnail> thumbnail() {
            return texture == null ? Optional.empty() : Optional.of(new Thumbnail(texture, !real));
        }

        /**
         * Advances this entry by whatever is available without blocking: start a
         * request, or take delivery of one.
         */
        private void poll() {
            if (real || failed) {
                return;
            }
            if (pending == null) {
                // Empty means the loader is saturated -- a "not now", not a
                // failure. Asking again next frame is the retry.
                pending = loader.request(asset.id(), size).orElse(null);
                return;
            }
            if (!pending.isDone()) {
                return;
            }
            CompletableFuture<byte[]> finished = pending;
            pending = null;
            try {
                adopt(textures.decode(finished.join()));
                real = true;
            } catch (IOException | RuntimeException e) {
                log.debug("Thumbnail {} is not drawable: {}", asset.id(), e.toString());
                failed = true;
            }
        }

        /** Swaps in a new texture and releases whatever it replaces. */
        private void adopt(TextureHandle replacement) {
            if (texture != null) {
                texture.close();
            }
            texture = replacement;
        }

        @Override
        public void close() {
            if (pending != null) {
                pending.cancel(false);
                pending = null;
            }
            if (texture != null) {
                texture.close();
                texture = null;
            }
        }

        private TextureHandle decodeHash(RemoteAsset source) {
            return source.thumbHash()
                    .flatMap(ThumbHash::decode)
                    .map(ThumbnailCache.this::upload)
                    .orElse(null);
        }
    }

    private TextureHandle upload(ThumbImage image) {
        return textures.upload(image);
    }
}
