package dev.photosync.core.provider.google;

import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
import dev.photosync.core.provider.ProviderException;
import dev.photosync.core.provider.TransferCancelledException;
import dev.photosync.core.provider.TransferProgress;

import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.lang.reflect.Type;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import java.util.StringJoiner;

/**
 * Everything HTTP about talking to the Google Photos Library API: bearer
 * tokens, timeouts, the upload protocol, and turning a status code into a
 * {@link ProviderException} the upload queue can act on.
 *
 * <p>Kept apart from {@link GooglePhotosProvider} for the same reason Immich's
 * is: the provider then reads as a list of API calls and their mapping onto
 * PhotoSync's model, with no transport noise in between.
 */
final class GooglePhotosApi {

    private static final URI ROOT = URI.create("https://photoslibrary.googleapis.com");
    private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30);
    /** Uploads get their own budget: a screenshot over a slow uplink is not a hung server. */
    private static final Duration UPLOAD_TIMEOUT = Duration.ofMinutes(10);
    /** Report progress at most this often, so a fast link is not drowned in callbacks. */
    private static final int PROGRESS_STRIDE_BYTES = 64 * 1024;

    private final HttpClient http;
    private final GoogleAuth auth;
    private final Gson gson = new Gson();

    GooglePhotosApi(GoogleAuth auth) {
        this.auth = auth;
        this.http = auth.http();
    }

    <T> T get(String path, Map<String, String> query, Type type) throws ProviderException {
        HttpRequest.Builder request = HttpRequest.newBuilder(uri(path, query))
                .header("Accept", "application/json")
                .timeout(REQUEST_TIMEOUT)
                .GET();
        return decode(send(request), type);
    }

    <T> T post(String path, Object body, Type type) throws ProviderException {
        HttpRequest.Builder request = HttpRequest.newBuilder(uri(path, Map.of()))
                .header("Accept", "application/json")
                .header("Content-Type", "application/json")
                .timeout(REQUEST_TIMEOUT)
                .POST(HttpRequest.BodyPublishers.ofString(gson.toJson(body), StandardCharsets.UTF_8));
        return decode(send(request), type);
    }

    /**
     * Streams a file to {@code /v1/uploads} and returns the upload token that
     * {@code mediaItems:batchCreate} turns into a real media item.
     *
     * <p>This endpoint is not JSON in either direction. The body is the raw file
     * -- which is why it is streamed from disk rather than read into a byte
     * array, on a heap the game is already competing for -- and the answer is
     * the token as bare text with no envelope around it.
     */
    String upload(Path file, String mimeType, TransferProgress progress) throws ProviderException {
        long size;
        try {
            size = Files.size(file);
        } catch (IOException e) {
            throw new ProviderException(ProviderException.Kind.SOURCE_UNREADABLE, "Cannot read " + file, e);
        }
        HttpRequest.Builder request = HttpRequest.newBuilder(uri("/v1/uploads", Map.of()))
                .header("Content-Type", "application/octet-stream")
                .header("X-Goog-Upload-Content-Type", mimeType)
                // "raw" is the whole file in one request. The alternative,
                // "resumable", buys the ability to continue an interrupted
                // upload -- worth having for a video, not for a screenshot,
                // and it costs two extra round trips on every one of them.
                .header("X-Goog-Upload-Protocol", "raw")
                .timeout(UPLOAD_TIMEOUT)
                .POST(streamOf(file, size, progress));

        String token = new String(send(request), StandardCharsets.UTF_8).trim();
        if (token.isEmpty()) {
            throw new ProviderException(ProviderException.Kind.PROTOCOL,
                    "Google accepted the upload but returned no upload token");
        }
        return token;
    }

    /**
     * Fetches image bytes from a {@code baseUrl}.
     *
     * <p>Deliberately unauthenticated. These URLs carry their own signed
     * credential -- Google's own samples put them straight into an
     * {@code <img src>} -- and sending a bearer token to a host outside the API
     * endpoint is a habit worth not having.
     */
    byte[] fetch(String url) throws ProviderException {
        HttpRequest request = HttpRequest.newBuilder(URI.create(url))
                .timeout(REQUEST_TIMEOUT)
                .GET()
                .build();
        try {
            HttpResponse<byte[]> response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
            if (response.statusCode() / 100 == 2) {
                return response.body();
            }
            throw failure(response.statusCode(), response.body());
        } catch (IOException e) {
            throw new ProviderException(ProviderException.Kind.NETWORK,
                    "Could not download the image from Google: " + e.getMessage(), e);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new ProviderException(ProviderException.Kind.NETWORK, "Interrupted while downloading from Google", e);
        }
    }

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

    /**
     * Sends with a bearer token, and once more with a fresh one if Google says
     * the first was no good.
     *
     * <p>An access token lives an hour and {@link GoogleAuth} renews it five
     * minutes early, so this should not happen -- but a clock that is wrong, a
     * session suspended mid-request, or a token revoked and re-granted all land
     * here, and re-asking once is cheaper than failing an upload the player
     * would have to notice and retry.
     */
    private byte[] send(HttpRequest.Builder builder) throws ProviderException {
        try {
            return send(builder.copy().header("Authorization", "Bearer " + auth.bearer()).build());
        } catch (ProviderException e) {
            if (e.kind() != ProviderException.Kind.AUTHENTICATION) {
                throw e;
            }
            auth.forgetAccessToken();
            return send(builder.copy().header("Authorization", "Bearer " + auth.bearer()).build());
        }
    }

    private byte[] send(HttpRequest request) throws ProviderException {
        try {
            HttpResponse<byte[]> response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
            int status = response.statusCode();
            if (status >= 200 && status < 300) {
                return response.body();
            }
            throw failure(status, response.body());
        } catch (IOException e) {
            // A cancelled transfer unwinds through the request body stream and
            // the client hands it back wrapped. It is not a network failure and
            // must not be retried, so dig it out before classifying anything.
            TransferCancelledException cancelled = findCancellation(e);
            if (cancelled != null) {
                throw cancelled;
            }
            throw new ProviderException(ProviderException.Kind.NETWORK,
                    "Could not reach Google Photos: " + e.getMessage(), e);
        } catch (UncheckedIOException e) {
            throw new ProviderException(ProviderException.Kind.SOURCE_UNREADABLE,
                    "Could not read the file to upload: " + e.getMessage(), e);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new ProviderException(ProviderException.Kind.NETWORK, "Interrupted while waiting for Google", e);
        }
    }

    private ProviderException failure(int status, byte[] body) {
        ProviderException.Kind kind = switch (status) {
            case 401 -> ProviderException.Kind.AUTHENTICATION;
            // 403 is overloaded here. Google returns it both for a token that
            // may not do this and for a project that has run out of quota, and
            // only the message tells them apart -- so the message decides
            // whether the queue waits or gives up.
            case 403 -> looksLikeQuota(body)
                    ? ProviderException.Kind.RATE_LIMITED
                    : ProviderException.Kind.AUTHENTICATION;
            case 404 -> ProviderException.Kind.NOT_FOUND;
            case 408, 429 -> ProviderException.Kind.RATE_LIMITED;
            default -> status >= 500 ? ProviderException.Kind.SERVER : ProviderException.Kind.PROTOCOL;
        };
        return new ProviderException(kind, "Google Photos returned HTTP " + status + " (" + describe(body) + ")");
    }

    private boolean looksLikeQuota(byte[] body) {
        String status = messageOf(body).orElse("");
        return status.contains("quota") || status.contains("Quota") || status.contains("rate limit");
    }

    /** Google answers errors as JSON; fall back to the raw text when it does not. */
    private String describe(byte[] body) {
        String text = new String(body, StandardCharsets.UTF_8).trim();
        return messageOf(body)
                .filter(message -> !message.isBlank())
                .orElseGet(() -> text.isEmpty() ? "no details" : abbreviate(text));
    }

    private Optional<String> messageOf(byte[] body) {
        String text = new String(body, StandardCharsets.UTF_8).trim();
        if (!text.startsWith("{")) {
            return Optional.empty();
        }
        try {
            GoogleDtos.ApiError error = gson.fromJson(text, GoogleDtos.ApiError.class);
            return Optional.ofNullable(error)
                    .map(GoogleDtos.ApiError::error)
                    .map(GoogleDtos.ErrorBody::message);
        } catch (JsonSyntaxException ignored) {
            return Optional.empty();
        }
    }

    private <T> T decode(byte[] body, Type type) throws ProviderException {
        try {
            T value = gson.fromJson(new String(body, StandardCharsets.UTF_8), type);
            if (value == null) {
                throw new ProviderException(ProviderException.Kind.PROTOCOL,
                        "Google Photos returned an empty response");
            }
            return value;
        } catch (JsonSyntaxException e) {
            throw new ProviderException(ProviderException.Kind.PROTOCOL,
                    "Google Photos returned something that is not the JSON we expected", e);
        }
    }

    private static URI uri(String path, Map<String, String> query) {
        StringBuilder url = new StringBuilder(ROOT.toString()).append(path);
        if (!query.isEmpty()) {
            StringJoiner joiner = new StringJoiner("&");
            query.forEach((key, value) -> joiner.add(
                    URLEncoder.encode(key, StandardCharsets.UTF_8) + "="
                            + URLEncoder.encode(value, StandardCharsets.UTF_8)));
            url.append('?').append(joiner);
        }
        return URI.create(url.toString());
    }

    /**
     * The file as a request body with a real {@code Content-Length}, reporting
     * bytes as the client pulls them.
     *
     * <p>The supplier may be called more than once -- a redirect replays the
     * body -- so it opens a fresh stream each time rather than capturing one.
     */
    private static HttpRequest.BodyPublisher streamOf(Path file, long size, TransferProgress progress) {
        return HttpRequest.BodyPublishers.fromPublisher(
                HttpRequest.BodyPublishers.ofInputStream(() -> {
                    try {
                        return new CountingStream(Files.newInputStream(file), progress, size);
                    } catch (IOException e) {
                        // The supplier cannot throw a checked exception; the
                        // HttpClient unwraps this back into one on send().
                        throw new UncheckedIOException(e);
                    }
                }),
                size);
    }

    /**
     * Counts bytes as the HTTP client pulls them, and tells the queue.
     *
     * <p>The callback may throw -- that is how a cancelled upload unwinds -- so
     * it is invoked where the exception can propagate out of {@code read}.
     */
    private static final class CountingStream extends FilterInputStream {

        private final TransferProgress progress;
        private final long total;
        private long sent;
        private long lastReported;

        CountingStream(InputStream delegate, TransferProgress progress, long total) {
            super(delegate);
            this.progress = progress;
            this.total = total;
        }

        @Override
        public int read() throws IOException {
            int value = super.read();
            if (value >= 0) {
                advance(1);
            }
            return value;
        }

        @Override
        public int read(byte[] buffer, int offset, int length) throws IOException {
            int count = super.read(buffer, offset, length);
            if (count > 0) {
                advance(count);
            }
            return count;
        }

        private void advance(int count) {
            sent += count;
            if (sent - lastReported >= PROGRESS_STRIDE_BYTES || sent == total) {
                lastReported = sent;
                progress.onProgress(sent, total);
            }
        }
    }

    private static TransferCancelledException findCancellation(Throwable thrown) {
        for (Throwable cause = thrown; cause != null; cause = cause.getCause()) {
            if (cause instanceof TransferCancelledException cancelled) {
                return cancelled;
            }
            if (cause.getCause() == cause) {
                break;
            }
        }
        return null;
    }

    private static String abbreviate(String text) {
        String flat = text.replaceAll("\\s+", " ");
        return flat.length() <= 160 ? flat : flat.substring(0, 157) + "...";
    }
}
