package dev.photosync.core.provider.google;

import dev.photosync.core.provider.Album;
import dev.photosync.core.provider.AlbumRef;
import dev.photosync.core.provider.AssetKind;
import dev.photosync.core.provider.BucketPage;
import dev.photosync.core.provider.PhotoProvider;
import dev.photosync.core.provider.ProviderConnection;
import dev.photosync.core.provider.ProviderDescriptor;
import dev.photosync.core.provider.ProviderException;
import dev.photosync.core.provider.ProviderIdentity;
import dev.photosync.core.provider.RemoteAsset;
import dev.photosync.core.provider.ThumbnailSize;
import dev.photosync.core.provider.TimelineBucket;
import dev.photosync.core.provider.TransferProgress;
import dev.photosync.core.provider.UploadReceipt;
import dev.photosync.core.provider.UploadRequest;
import lombok.extern.slf4j.Slf4j;

import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;

/**
 * Google Photos, expressed in PhotoSync's terms.
 *
 * <p>Three things about this API shape everything below, and all three are
 * worth knowing before reading further.
 *
 * <p><b>It only ever shows you what this mod uploaded.</b> Google withdrew the
 * broad library scopes in March 2025. What remains is "add media" and "read
 * back the media you added", so the Browse tab is a view of PhotoSync's own
 * uploads -- not of the player's Google Photos account. That is a permission
 * boundary, not a setting.
 *
 * <p><b>There is no bucket endpoint.</b> Immich hands over every month and its
 * count in one cheap call, which is what makes its timeline a true virtual
 * list. Google has nothing of the kind, so {@link #timeline} pages through the
 * media items itself and groups them into months, then keeps the result so
 * {@link #page} is free. The cost is bounded by {@link #MAX_SCAN_PAGES}; past
 * that the timeline is truncated and says so in the log.
 *
 * <p><b>Image URLs expire.</b> Nothing here is a stable image address. Each
 * media item carries a {@code baseUrl} that is good for about an hour and has
 * the wanted dimensions appended to it, so this class caches those URLs and
 * refetches the item when one stops working.
 */
@Slf4j
public final class GooglePhotosProvider implements PhotoProvider {

    /** Google's maximum for both listing endpoints. Fewer pages is fewer round trips. */
    private static final int SCAN_PAGE_SIZE = 100;
    /** A ceiling on what one Browse tab costs: 50 pages of 100 is 5000 items. */
    private static final int MAX_SCAN_PAGES = 50;
    /** How long a scan is reused before the timeline is walked again. */
    private static final Duration SCAN_LIFETIME = Duration.ofMinutes(2);
    /**
     * How long a {@code baseUrl} is trusted. Google documents roughly 60 minutes;
     * this leaves a margin so a URL does not expire between being handed out and
     * being fetched.
     */
    private static final Duration BASE_URL_LIFETIME = Duration.ofMinutes(45);

    /** The longest side to ask for, per {@link ThumbnailSize}. */
    private static final int GRID_PIXELS = 512;
    private static final int DETAIL_PIXELS = 2048;

    /** Google rejects a longer one, and the queue would rather truncate than fail. */
    private static final int MAX_DESCRIPTION = 1000;

    private final ProviderDescriptor descriptor;
    private final GoogleAuth auth;
    private final GooglePhotosApi api;

    /** {@code baseUrl}s learned from scans and item fetches, by media item id. */
    private final Map<String, CachedUrl> baseUrls = new ConcurrentHashMap<>();

    private final Object scanLock = new Object();
    private Scan scan;

    GooglePhotosProvider(ProviderDescriptor descriptor,
                         ProviderConnection connection,
                         Supplier<Path> tokenFile,
                         SignInPrompt prompt) {
        this.descriptor = descriptor;
        this.auth = new GoogleAuth(connection, tokenFile, prompt);
        this.api = new GooglePhotosApi(auth);
    }

    @Override
    public ProviderDescriptor descriptor() {
        return descriptor;
    }

    /**
     * Signs in if that has not happened yet, then names the account.
     *
     * <p>This is the one method allowed to open a browser, and it is reached
     * only from the "Test connection" button -- so consent appears because the
     * player just asked for it, never in the middle of a game. Everything else
     * goes through {@link GoogleAuth#bearer()}, which fails rather than prompts.
     */
    @Override
    public ProviderIdentity identify() throws ProviderException {
        String account = auth.signIn();
        return new ProviderIdentity("Google Photos Library API v1",
                account.isEmpty() ? "signed in" : account);
    }

    /**
     * Albums this mod created.
     *
     * <p>{@code excludeNonAppCreatedData} is not a filter for tidiness: the
     * appendonly scope cannot add media to an album it did not create, so
     * offering the player's other albums would be offering uploads that are
     * going to be refused.
     */
    @Override
    public List<Album> albums() throws ProviderException {
        List<Album> albums = new ArrayList<>();
        String pageToken = null;
        int pages = 0;
        do {
            Map<String, String> query = new LinkedHashMap<>();
            query.put("pageSize", "50");
            query.put("excludeNonAppCreatedData", "true");
            if (pageToken != null) {
                query.put("pageToken", pageToken);
            }
            GoogleDtos.AlbumsResponse response =
                    api.get("/v1/albums", query, GoogleDtos.AlbumsResponse.class);
            if (response.albums() != null) {
                for (GoogleDtos.AlbumSummary summary : response.albums()) {
                    if (summary.id() == null) {
                        continue;
                    }
                    albums.add(new Album(
                            summary.id(),
                            Optional.ofNullable(summary.title()).orElse(summary.id()),
                            (int) Math.min(Integer.MAX_VALUE, number(summary.mediaItemsCount(), 0)),
                            Optional.ofNullable(summary.coverPhotoMediaItemId())));
                }
            }
            pageToken = response.nextPageToken();
            pages++;
        } while (pageToken != null && !pageToken.isBlank() && pages < MAX_SCAN_PAGES);

        return albums.stream()
                .sorted(Comparator.comparing(Album::name, String.CASE_INSENSITIVE_ORDER))
                .toList();
    }

    @Override
    public Album createAlbum(String name) throws ProviderException {
        GoogleDtos.AlbumSummary created = api.post("/v1/albums",
                new GoogleDtos.CreateAlbumRequest(new GoogleDtos.NewAlbum(name)),
                GoogleDtos.AlbumSummary.class);
        if (created.id() == null) {
            throw new ProviderException(ProviderException.Kind.PROTOCOL,
                    "Google Photos created an album without an id");
        }
        return new Album(created.id(), name, 0, Optional.empty());
    }

    /**
     * Uploads in Google's two steps: the bytes go to {@code /v1/uploads}, which
     * answers with a token, and the token becomes a real media item through
     * {@code mediaItems:batchCreate}.
     *
     * <p><b>The idempotency the interface asks for cannot be delivered here.</b>
     * {@link PhotoProvider#upload} requires that re-sending bytes the backend
     * already holds come back as {@link UploadReceipt.Outcome#DUPLICATE}, and
     * Google's API has no such concept: there is no checksum header, no
     * duplicate status, and no way to ask whether an item exists. (The Google
     * Photos app deduplicates its own backups; the API does not expose it.) So
     * every upload that succeeds is reported as {@code CREATED}, and a job the
     * queue retries because the first response was lost will leave two copies in
     * the account. Nothing in this class can prevent that -- it is worth knowing
     * rather than working around.
     */
    @Override
    public UploadReceipt upload(UploadRequest request, TransferProgress progress) throws ProviderException {
        Path file = request.file();
        if (!Files.isReadable(file)) {
            throw new ProviderException(ProviderException.Kind.SOURCE_UNREADABLE, "Cannot read " + file);
        }

        String uploadToken = api.upload(file, mediaTypeOf(request.fileName()), progress);

        String description = request.description().length() > MAX_DESCRIPTION
                ? request.description().substring(0, MAX_DESCRIPTION)
                : request.description();
        GoogleDtos.BatchCreateRequest body = new GoogleDtos.BatchCreateRequest(
                request.album().id().orElse(null),
                List.of(new GoogleDtos.NewMediaItem(
                        description.isBlank() ? null : description,
                        new GoogleDtos.SimpleMediaItem(uploadToken, request.fileName()))));

        GoogleDtos.BatchCreateResponse response =
                api.post("/v1/mediaItems:batchCreate", body, GoogleDtos.BatchCreateResponse.class);

        List<GoogleDtos.NewMediaItemResult> results = response.newMediaItemResults();
        if (results == null || results.isEmpty()) {
            throw new ProviderException(ProviderException.Kind.PROTOCOL,
                    "Google Photos accepted the upload but said nothing about what it did with it");
        }
        GoogleDtos.NewMediaItemResult result = results.get(0);

        // batchCreate answers 200 even when the item inside it failed; the
        // per-item status is the real outcome. Code 0 is OK in Google's status
        // model, and an absent status means the same.
        int code = result.status() == null || result.status().code() == null ? 0 : result.status().code();
        if (code != 0 || result.mediaItem() == null || result.mediaItem().id() == null) {
            String detail = result.status() == null || result.status().message() == null
                    ? "no reason given"
                    : result.status().message();
            throw new ProviderException(ProviderException.Kind.PROTOCOL,
                    "Google Photos would not store the upload: " + detail);
        }

        remember(result.mediaItem());
        // Always CREATED -- see the note above. Reporting DUPLICATE would be a lie.
        return new UploadReceipt(result.mediaItem().id(), UploadReceipt.Outcome.CREATED);
    }

    @Override
    public List<TimelineBucket> timeline(AlbumRef album) throws ProviderException {
        return scanFor(album).buckets();
    }

    @Override
    public BucketPage page(AlbumRef album, TimelineBucket bucket) throws ProviderException {
        List<RemoteAsset> assets = scanFor(album).pages().get(bucket.key());
        return new BucketPage(bucket, assets == null ? List.of() : assets);
    }

    /**
     * Image bytes at roughly the requested size.
     *
     * <p>Google serves these from a {@code baseUrl} with the wanted box appended
     * -- {@code =w512-h512} means "fit inside 512 by 512" -- and always as JPEG,
     * which is what the game's decoder wants. Videos have no separate thumbnail
     * endpoint; the same URL without a {@code =dv} suffix returns a still frame,
     * so a video tile needs no special case.
     *
     * <p>A {@code baseUrl} stops working roughly an hour after it was issued,
     * and a player can leave the Browse tab open longer than that. So an expiry
     * is not an error: the URL is dropped, the media item is fetched again for a
     * fresh one, and the request is made once more.
     */
    @Override
    public byte[] thumbnail(String assetId, ThumbnailSize size) throws ProviderException {
        int pixels = size == ThumbnailSize.DETAIL ? DETAIL_PIXELS : GRID_PIXELS;
        String url = baseUrlOf(assetId, false);
        try {
            return api.fetch(sized(url, pixels));
        } catch (ProviderException e) {
            boolean stale = e.kind() == ProviderException.Kind.NOT_FOUND
                    || e.kind() == ProviderException.Kind.AUTHENTICATION;
            if (!stale) {
                throw e;
            }
            log.debug("The image URL for {} has expired; asking Google for a new one", assetId);
            return api.fetch(sized(baseUrlOf(assetId, true), pixels));
        }
    }

    @Override
    public void close() {
        // Nothing to release: java.net.http.HttpClient has no close() before
        // Java 21, and this module compiles to 17. It uses daemon threads and is
        // collected with the provider.
    }

    // -----------------------------------------------------------------------
    // The synthesised timeline
    // -----------------------------------------------------------------------

    /**
     * One walk of the media items, grouped into months.
     *
     * <p>Held whole rather than per month because that is how it was fetched:
     * Google's paging has no way to ask for "June", so once the pages have been
     * read there is no reason to make {@link #page} pay for them again.
     */
    private record Scan(AlbumRef album,
                        Instant takenAt,
                        List<TimelineBucket> buckets,
                        Map<String, List<RemoteAsset>> pages) {
    }

    private Scan scanFor(AlbumRef album) throws ProviderException {
        synchronized (scanLock) {
            Scan current = scan;
            boolean usable = current != null
                    && current.album().equals(album)
                    && Instant.now().isBefore(current.takenAt().plus(SCAN_LIFETIME));
            if (usable) {
                return current;
            }
            Scan fresh = rescan(album);
            scan = fresh;
            return fresh;
        }
    }

    private Scan rescan(AlbumRef album) throws ProviderException {
        List<GoogleDtos.MediaItem> items = allItems(album);

        Map<String, List<RemoteAsset>> pages = new LinkedHashMap<>();
        for (GoogleDtos.MediaItem item : items) {
            RemoteAsset asset = assetOf(item);
            if (asset == null) {
                continue;
            }
            remember(item);
            pages.computeIfAbsent(monthKey(asset.localDay()), key -> new ArrayList<>()).add(asset);
        }

        List<TimelineBucket> buckets = new ArrayList<>(pages.size());
        for (Map.Entry<String, List<RemoteAsset>> entry : pages.entrySet()) {
            entry.getValue().sort(Comparator.comparing(RemoteAsset::localCapturedAt).reversed());
            buckets.add(new TimelineBucket(entry.getKey(), LocalDate.parse(entry.getKey() + "-01"),
                    entry.getValue().size()));
        }
        buckets.sort(Comparator.naturalOrder());

        log.debug("Google Photos timeline: {} items in {} months", items.size(), buckets.size());
        return new Scan(album, Instant.now(), List.copyOf(buckets), pages);
    }

    /**
     * Every media item in scope, up to the scan ceiling.
     *
     * <p>Note the asymmetry in what truncation costs. The library listing comes
     * back newest first, so a cut-off drops the oldest items -- the ones furthest
     * down the timeline. An album search comes back in album order, so a cut-off
     * there drops the newest. Both are logged; only the second is likely to be
     * noticed, and only by someone with more than {@link #MAX_SCAN_PAGES} pages
     * of screenshots in one album.
     */
    private List<GoogleDtos.MediaItem> allItems(AlbumRef album) throws ProviderException {
        List<GoogleDtos.MediaItem> items = new ArrayList<>();
        String pageToken = null;
        int pages = 0;
        do {
            GoogleDtos.MediaItemsResponse response;
            if (album.isLibrary()) {
                Map<String, String> query = new LinkedHashMap<>();
                query.put("pageSize", Integer.toString(SCAN_PAGE_SIZE));
                if (pageToken != null) {
                    query.put("pageToken", pageToken);
                }
                response = api.get("/v1/mediaItems", query, GoogleDtos.MediaItemsResponse.class);
            } else {
                response = api.post("/v1/mediaItems:search",
                        new GoogleDtos.SearchRequest(album.id().orElseThrow(), SCAN_PAGE_SIZE, pageToken),
                        GoogleDtos.MediaItemsResponse.class);
            }
            if (response.mediaItems() != null) {
                items.addAll(response.mediaItems());
            }
            pageToken = response.nextPageToken();
            pages++;
        } while (pageToken != null && !pageToken.isBlank() && pages < MAX_SCAN_PAGES);

        if (pageToken != null && !pageToken.isBlank()) {
            log.info("Showing the first {} Google Photos items; there are more than PhotoSync browses in one go",
                    items.size());
        }
        return items;
    }

    private static RemoteAsset assetOf(GoogleDtos.MediaItem item) {
        if (item.id() == null || item.mediaMetadata() == null) {
            return null;
        }
        Instant capturedAt = instantOf(item.mediaMetadata().creationTime());
        if (capturedAt == null) {
            // Without a timestamp the asset has no day to live under.
            return null;
        }
        long width = number(item.mediaMetadata().width(), 0);
        long height = number(item.mediaMetadata().height(), 0);
        boolean video = item.mimeType() != null && item.mimeType().startsWith("video/");
        return new RemoteAsset(
                item.id(),
                video ? AssetKind.VIDEO : AssetKind.IMAGE,
                capturedAt,
                localTimeOf(capturedAt),
                height > 0 ? (double) width / height : 1.0,
                // Google has no ThumbHash equivalent, and computing one would
                // mean downloading every image first -- exactly what the blurred
                // placeholder exists to avoid. Tiles fill in as they load.
                Optional.empty(),
                Duration.ZERO);
    }

    /**
     * The capture instant read in the viewer's own time zone.
     *
     * <p>{@link RemoteAsset} wants the wall clock where the photo was taken, and
     * Google -- unlike Immich -- returns no UTC offset to reconstruct it with.
     * The player's zone is the closest available answer and is the right one in
     * the ordinary case, where the screenshots were taken on this machine.
     * Someone browsing from another continent will see days grouped by the
     * calendar they are reading them on.
     */
    private static LocalDateTime localTimeOf(Instant instant) {
        return LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
    }

    private static String monthKey(LocalDate day) {
        return String.format(Locale.ROOT, "%04d-%02d", day.getYear(), day.getMonthValue());
    }

    // -----------------------------------------------------------------------
    // Image URLs
    // -----------------------------------------------------------------------

    private record CachedUrl(String url, Instant fetchedAt) {
    }

    private void remember(GoogleDtos.MediaItem item) {
        if (item.id() != null && item.baseUrl() != null && !item.baseUrl().isBlank()) {
            baseUrls.put(item.id(), new CachedUrl(item.baseUrl(), Instant.now()));
        }
    }

    private String baseUrlOf(String assetId, boolean force) throws ProviderException {
        if (!force) {
            CachedUrl cached = baseUrls.get(assetId);
            if (cached != null && Instant.now().isBefore(cached.fetchedAt().plus(BASE_URL_LIFETIME))) {
                return cached.url();
            }
        }
        GoogleDtos.MediaItem item =
                api.get("/v1/mediaItems/" + assetId, Map.of(), GoogleDtos.MediaItem.class);
        if (item.baseUrl() == null || item.baseUrl().isBlank()) {
            throw new ProviderException(ProviderException.Kind.NOT_FOUND,
                    "Google Photos has no image for " + assetId);
        }
        remember(item);
        return item.baseUrl();
    }

    /** Google's sizing suffix: fit the image inside a box this many pixels on a side. */
    private static String sized(String baseUrl, int pixels) {
        return baseUrl + "=w" + pixels + "-h" + pixels;
    }

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

    private static Instant instantOf(String rfc3339) {
        if (rfc3339 == null) {
            return null;
        }
        try {
            return Instant.parse(rfc3339);
        } catch (DateTimeParseException e) {
            log.debug("Ignoring a Google Photos item with an unreadable creation time: {}", rfc3339);
            return null;
        }
    }

    /** Google JSON-encodes its 64-bit integers as strings; this is the way back. */
    private static long number(String value, long fallback) {
        if (value == null || value.isBlank()) {
            return fallback;
        }
        try {
            return Long.parseLong(value.trim());
        } catch (NumberFormatException e) {
            return fallback;
        }
    }

    private static String mediaTypeOf(String fileName) {
        String lower = fileName.toLowerCase(Locale.ROOT);
        if (lower.endsWith(".png")) {
            return "image/png";
        }
        if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) {
            return "image/jpeg";
        }
        if (lower.endsWith(".webp")) {
            return "image/webp";
        }
        return "application/octet-stream";
    }
}
