package dev.photosync.core.timeline;

import dev.photosync.core.provider.AlbumRef;
import dev.photosync.core.provider.BucketPage;
import dev.photosync.core.provider.PhotoProvider;
import dev.photosync.core.provider.ProviderException;
import dev.photosync.core.provider.ProviderSession;
import dev.photosync.core.provider.RemoteAsset;
import dev.photosync.core.provider.TimelineBucket;
import lombok.extern.slf4j.Slf4j;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * The lazily-filled model behind the album browser.
 *
 * <p>It fetches the month counts once, which is enough to lay the whole album
 * out at full height, and then fetches a month's assets only when the renderer
 * says that month is about to be on screen. Everything here runs off the render
 * thread; the renderer only ever reads {@link #sections()} and {@link #revision()},
 * both of which are non-blocking.
 *
 * <p>The renderer drives loading by calling {@link #request(TimelineBucket)} for
 * the months it is about to draw. Repeat calls are free -- already-loaded,
 * in-flight and previously-failed months are all ignored -- so it can call it
 * every frame without keeping any bookkeeping of its own.
 */
@Slf4j
public final class TimelineBrowser implements AutoCloseable {

    private final ProviderSession session;
    private final ExecutorService loader;
    private final Object lock = new Object();
    private final Set<String> inFlight = ConcurrentHashMap.newKeySet();
    private final AtomicInteger revision = new AtomicInteger();

    private final Map<String, List<TimelineSection.Day>> pages = new HashMap<>();
    private final Map<String, String> pageErrors = new HashMap<>();

    private AlbumRef album = AlbumRef.library();
    private List<TimelineBucket> buckets = List.of();
    /** Bumped on every {@link #open} so results for the previous album are discarded. */
    private long generation;

    private volatile List<TimelineSection> sections = List.of();
    private volatile TimelineState state = TimelineState.NOT_CONFIGURED;
    private volatile String error;

    public TimelineBrowser(ProviderSession session) {
        this.session = session;
        this.loader = Executors.newFixedThreadPool(2, daemonFactory());
    }

    /** Points the browser at an album (or the whole library) and starts loading. */
    public void open(AlbumRef target) {
        long token;
        synchronized (lock) {
            album = target;
            buckets = List.of();
            pages.clear();
            pageErrors.clear();
            sections = List.of();
            error = null;
            token = ++generation;
        }
        inFlight.clear();
        state = TimelineState.LOADING;
        revision.incrementAndGet();
        loader.execute(() -> loadBuckets(token));
    }

    /** Re-fetches everything for the album already open -- the refresh button. */
    public void reload() {
        AlbumRef target;
        synchronized (lock) {
            target = album;
        }
        open(target);
    }

    /**
     * The current layout: one entry per calendar day for loaded months, one per
     * month for the rest. Cheap enough to call every frame -- it is a cached
     * immutable list, rebuilt only when something actually arrives.
     */
    public List<TimelineSection> sections() {
        return sections;
    }

    /** Changes whenever {@link #sections()} does, so the renderer knows to re-measure. */
    public int revision() {
        return revision.get();
    }

    public TimelineState state() {
        return state;
    }

    public Optional<String> error() {
        return Optional.ofNullable(error);
    }

    /** Total assets across the album, known before anything is fetched. */
    public int assetCount() {
        synchronized (lock) {
            return buckets.stream().mapToInt(TimelineBucket::assetCount).sum();
        }
    }

    /** Asks for a month's assets. Loaded, loading and failed months are no-ops. */
    public void request(TimelineBucket bucket) {
        long token;
        synchronized (lock) {
            if (pages.containsKey(bucket.key()) || pageErrors.containsKey(bucket.key())) {
                return;
            }
            token = generation;
        }
        if (!inFlight.add(bucket.key())) {
            return;
        }
        loader.execute(() -> loadPage(token, bucket));
    }

    /** Why a month's tiles are still blank, if it failed rather than merely not being reached yet. */
    public Optional<String> pageError(TimelineBucket bucket) {
        synchronized (lock) {
            return Optional.ofNullable(pageErrors.get(bucket.key()));
        }
    }

    public boolean isLoading(TimelineBucket bucket) {
        return inFlight.contains(bucket.key());
    }

    /** Clears a month's recorded failure so the next {@link #request} tries again. */
    public void retryPage(TimelineBucket bucket) {
        synchronized (lock) {
            pageErrors.remove(bucket.key());
        }
        request(bucket);
    }

    @Override
    public void close() {
        loader.shutdownNow();
        try {
            loader.awaitTermination(2, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    private void loadBuckets(long token) {
        Optional<PhotoProvider> provider = session.provider();
        if (provider.isEmpty()) {
            state = TimelineState.NOT_CONFIGURED;
            revision.incrementAndGet();
            return;
        }
        AlbumRef target;
        synchronized (lock) {
            if (token != generation) {
                return;
            }
            target = album;
        }
        try {
            List<TimelineBucket> fetched = new ArrayList<>(provider.get().timeline(target));
            fetched.sort(Comparator.naturalOrder());
            synchronized (lock) {
                if (token != generation) {
                    return;
                }
                buckets = List.copyOf(fetched);
                rebuild();
            }
            state = fetched.isEmpty() ? TimelineState.EMPTY : TimelineState.READY;
        } catch (ProviderException e) {
            log.warn("Could not load the timeline: {}", e.getMessage());
            synchronized (lock) {
                if (token != generation) {
                    return;
                }
                error = e.getMessage();
            }
            state = TimelineState.FAILED;
        } finally {
            revision.incrementAndGet();
        }
    }

    private void loadPage(long token, TimelineBucket bucket) {
        try {
            Optional<PhotoProvider> provider = session.provider();
            if (provider.isEmpty()) {
                return;
            }
            AlbumRef target;
            synchronized (lock) {
                if (token != generation) {
                    return;
                }
                target = album;
            }
            BucketPage page = provider.get().page(target, bucket);
            List<TimelineSection.Day> days = groupByDay(page.assets());
            synchronized (lock) {
                if (token != generation) {
                    return;
                }
                pages.put(bucket.key(), days);
                rebuild();
            }
        } catch (ProviderException e) {
            log.warn("Could not load {}: {}", bucket.key(), e.getMessage());
            synchronized (lock) {
                if (token == generation) {
                    pageErrors.put(bucket.key(), e.getMessage());
                }
            }
        } finally {
            inFlight.remove(bucket.key());
            revision.incrementAndGet();
        }
    }

    /**
     * Splits a month into days, newest first.
     *
     * <p>Grouping is by the asset's <em>local</em> date -- the one the camera saw
     * -- so a photo taken at 23:00 stays on the day it was taken no matter which
     * timezone the player is browsing from.
     */
    private static List<TimelineSection.Day> groupByDay(List<RemoteAsset> assets) {
        Map<LocalDate, List<RemoteAsset>> byDay = new LinkedHashMap<>();
        for (RemoteAsset asset : assets) {
            byDay.computeIfAbsent(asset.localDay(), day -> new ArrayList<>()).add(asset);
        }
        List<TimelineSection.Day> days = new ArrayList<>(byDay.size());
        byDay.forEach((day, members) -> days.add(new TimelineSection.Day(day, members)));
        days.sort(Comparator.comparing(TimelineSection.Day::date).reversed());
        return List.copyOf(days);
    }

    /** Must hold {@link #lock}. */
    private void rebuild() {
        List<TimelineSection> built = new ArrayList<>();
        for (TimelineBucket bucket : buckets) {
            List<TimelineSection.Day> loaded = pages.get(bucket.key());
            if (loaded == null) {
                built.add(new TimelineSection.PendingMonth(bucket));
            } else {
                built.addAll(loaded);
            }
        }
        sections = List.copyOf(built);
    }

    private static ThreadFactory daemonFactory() {
        AtomicInteger counter = new AtomicInteger();
        return runnable -> {
            Thread thread = new Thread(runnable, "photosync-timeline-" + counter.incrementAndGet());
            thread.setDaemon(true);
            return thread;
        };
    }
}
