package dev.photosync.core.provider.google;

import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import dev.photosync.core.persistence.JsonFile;
import dev.photosync.core.provider.ProviderConnection;
import dev.photosync.core.provider.ProviderException;
import lombok.extern.slf4j.Slf4j;

import java.io.IOException;
import java.net.SocketTimeoutException;
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.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.StringJoiner;
import java.util.function.Supplier;

/**
 * The OAuth half of talking to Google Photos: getting a token, keeping it, and
 * getting a new one when it expires.
 *
 * <p>Google Photos has no API key. The only credential a player can hold is a
 * refresh token, and the only way to mint one is to send them to Google in a
 * browser and catch the redirect. So this class does three things: it runs that
 * flow once ({@link #signIn()}), it stores what comes back next to the settings
 * file, and from then on it quietly trades the refresh token for access tokens
 * ({@link #bearer()}).
 *
 * <p>The split between those two entry points is the important design decision
 * here. {@link #signIn()} can open a browser window; {@link #bearer()} never
 * can. An upload retrying in the background must not throw a consent page over
 * the game, so it fails with an authentication error instead and the player
 * signs in when <em>they</em> choose to, from the settings screen.
 *
 * <p>PKCE is used even though this is a confidential client with a secret,
 * because the secret is sitting in a config file on a player's machine and the
 * loopback redirect is reachable by anything else running as that user. It
 * costs two hashes.
 */
@Slf4j
final class GoogleAuth {

    private static final URI AUTHORIZATION_ENDPOINT = URI.create("https://accounts.google.com/o/oauth2/v2/auth");
    private static final URI TOKEN_ENDPOINT = URI.create("https://oauth2.googleapis.com/token");

    private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
    private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30);
    /** How long the player gets to find the browser window and press Allow. */
    private static final Duration CONSENT_TIMEOUT = Duration.ofMinutes(5);
    /** Renew early: an access token that expires halfway through an upload is a failed upload. */
    private static final Duration EXPIRY_MARGIN = Duration.ofMinutes(5);
    /** What Google actually issues, used only when a response omits the field. */
    private static final long DEFAULT_LIFETIME_SECONDS = 3600;

    /**
     * What PhotoSync asks Google for.
     *
     * <p>Google withdrew the broad library scopes in March 2025, so no app can
     * read a user's whole Google Photos account any more. What is left happens
     * to be exactly this mod's shape: permission to add media, and permission to
     * read back the media it added. The consequence is worth being blunt about,
     * because it surprises people -- the Browse tab shows the screenshots
     * PhotoSync uploaded, not the player's holiday photos.
     *
     * <p>{@code openid} and the email scope are here only so that "Test
     * connection" can name the account that was signed in. Without them the
     * settings screen can say the token works but not whose it is, and a player
     * with two Google accounts has no way to check they picked the right one.
     */
    private static final String SCOPES = String.join(" ",
            "openid",
            "https://www.googleapis.com/auth/userinfo.email",
            "https://www.googleapis.com/auth/photoslibrary.appendonly",
            "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata");

    /** What is kept on disk between sessions. The client id is recorded so a changed one invalidates it. */
    private record StoredSignIn(String clientId, String refreshToken, String account) {
    }

    private final HttpClient http;
    private final SecureRandom random = new SecureRandom();
    private final Object lock = new Object();

    private final String clientId;
    private final String clientSecret;
    private final Supplier<Path> tokenFile;
    private final SignInPrompt prompt;

    private String accessToken;
    private Instant expiresAt = Instant.EPOCH;
    private String refreshToken;
    private String account = "";
    private boolean loaded;

    GoogleAuth(ProviderConnection connection, Supplier<Path> tokenFile, SignInPrompt prompt) {
        this.clientId = connection.endpoint();
        this.clientSecret = connection.secret();
        this.tokenFile = tokenFile;
        this.prompt = prompt;
        this.http = HttpClient.newBuilder()
                .connectTimeout(CONNECT_TIMEOUT)
                .followRedirects(HttpClient.Redirect.NORMAL)
                .build();
    }

    /**
     * The one connection pool for everything PhotoSync says to Google.
     *
     * <p>{@link GooglePhotosApi} borrows it rather than building a second: the
     * token endpoint and the Library API are the same company's TLS termination,
     * and a mod running inside a game should not hold two pools to reach it.
     */
    HttpClient http() {
        return http;
    }

    /** Whoever was signed in, once anything has asked for a token. Empty before that. */
    String account() {
        synchronized (lock) {
            return account;
        }
    }

    /**
     * An access token for an API call, without ever prompting.
     *
     * <p>Fails rather than signing in, because this is reached from uploads and
     * thumbnail fetches -- work that happens while the player is doing something
     * else, where a browser window opening unbidden would be alarming.
     */
    String bearer() throws ProviderException {
        synchronized (lock) {
            String usable = unexpired();
            if (usable != null) {
                return usable;
            }
            load();
            if (refreshToken == null) {
                throw new ProviderException(ProviderException.Kind.AUTHENTICATION,
                        "PhotoSync is not signed in to Google Photos. "
                                + "Open the settings screen and use Test connection to sign in.");
            }
            return refresh();
        }
    }

    /**
     * Makes sure there is a working sign-in, running the browser flow if there
     * is not, and answers which account it belongs to.
     *
     * <p>This is what "Test connection" calls. It is the only path that may
     * block for minutes, and it is a direct answer to a button the player just
     * pressed.
     */
    String signIn() throws ProviderException {
        synchronized (lock) {
            load();
            if (refreshToken != null) {
                try {
                    refresh();
                    return account;
                } catch (ProviderException e) {
                    if (e.kind() != ProviderException.Kind.AUTHENTICATION) {
                        throw e;
                    }
                    // Revoked from the Google account page, or the client id in
                    // the settings was changed. Either way, start over.
                    log.info("The stored Google Photos sign-in no longer works; asking for a new one");
                    refreshToken = null;
                }
            }
            authorize();
            return account;
        }
    }

    /**
     * Throws away the current access token so the next {@link #bearer()} mints a
     * fresh one.
     *
     * <p>For the case the expiry margin cannot cover: Google saying 401 to a
     * token this class still believes in. A clock that is wrong, a laptop
     * suspended mid-request, or a grant revoked and re-issued all look like
     * that, and the answer to all three is to stop trusting what is cached.
     */
    void forgetAccessToken() {
        synchronized (lock) {
            accessToken = null;
            expiresAt = Instant.EPOCH;
        }
    }

    private String unexpired() {
        boolean fresh = accessToken != null && Instant.now().isBefore(expiresAt.minus(EXPIRY_MARGIN));
        return fresh ? accessToken : null;
    }

    // -----------------------------------------------------------------------
    // The browser flow
    // -----------------------------------------------------------------------

    private void authorize() throws ProviderException {
        String verifier = randomToken(64);
        String state = randomToken(24);
        try (LoopbackReceiver receiver = new LoopbackReceiver()) {
            String redirect = receiver.redirectUri();
            Map<String, String> query = new LinkedHashMap<>();
            query.put("client_id", clientId);
            query.put("redirect_uri", redirect);
            query.put("response_type", "code");
            query.put("scope", SCOPES);
            query.put("code_challenge", challengeFor(verifier));
            query.put("code_challenge_method", "S256");
            // Offline access is what makes Google issue a refresh token at all,
            // and it only does so on a consent it has not already recorded --
            // hence the forced prompt, which is the difference between signing
            // in once and signing in every hour.
            query.put("access_type", "offline");
            query.put("prompt", "consent");
            query.put("state", state);

            String url = AUTHORIZATION_ENDPOINT + "?" + encode(query);
            log.info("Waiting for a Google Photos sign-in from the browser on {}", redirect);
            prompt.show(url);

            Map<String, String> answer = receiver.awaitRedirect(CONSENT_TIMEOUT);
            String refusal = answer.get("error");
            if (refusal != null) {
                throw new ProviderException(ProviderException.Kind.AUTHENTICATION,
                        "Google would not sign PhotoSync in: " + refusal);
            }
            if (!state.equals(answer.get("state"))) {
                // Somebody other than the browser we sent got to the port first.
                throw new ProviderException(ProviderException.Kind.PROTOCOL,
                        "The sign-in that came back was not the one PhotoSync started");
            }
            String code = answer.get("code");
            if (code == null || code.isBlank()) {
                throw new ProviderException(ProviderException.Kind.PROTOCOL,
                        "Google's redirect carried no authorization code");
            }

            Map<String, String> form = new LinkedHashMap<>();
            form.put("grant_type", "authorization_code");
            form.put("code", code);
            form.put("client_id", clientId);
            form.put("client_secret", clientSecret);
            form.put("code_verifier", verifier);
            form.put("redirect_uri", redirect);
            adopt(exchange(form));
            log.info("Signed in to Google Photos as {}", account.isEmpty() ? "an unnamed account" : account);
        } catch (SocketTimeoutException e) {
            throw new ProviderException(ProviderException.Kind.AUTHENTICATION,
                    "The Google Photos sign-in was not finished in time. Press Test connection to try again.", e);
        } catch (IOException e) {
            throw new ProviderException(ProviderException.Kind.NETWORK,
                    "Could not listen for Google's answer: " + e.getMessage(), e);
        }
    }

    private String refresh() throws ProviderException {
        Map<String, String> form = new LinkedHashMap<>();
        form.put("grant_type", "refresh_token");
        form.put("refresh_token", refreshToken);
        form.put("client_id", clientId);
        form.put("client_secret", clientSecret);
        adopt(exchange(form));
        return accessToken;
    }

    /** Takes whatever a grant carried, keeping what it did not mention. */
    private void adopt(JsonObject grant) {
        accessToken = text(grant, "access_token");
        long lifetime = grant.has("expires_in")
                ? grant.get("expires_in").getAsLong()
                : DEFAULT_LIFETIME_SECONDS;
        expiresAt = Instant.now().plusSeconds(lifetime);
        // A refresh response repeats neither the refresh token nor, always, the
        // identity: absence means unchanged, not revoked.
        String issued = text(grant, "refresh_token");
        if (issued != null) {
            refreshToken = issued;
        }
        String email = emailIn(text(grant, "id_token"));
        if (email != null) {
            account = email;
        }
        store();
    }

    private JsonObject exchange(Map<String, String> form) throws ProviderException {
        HttpRequest request = HttpRequest.newBuilder(TOKEN_ENDPOINT)
                .header("Content-Type", "application/x-www-form-urlencoded")
                .header("Accept", "application/json")
                .timeout(REQUEST_TIMEOUT)
                .POST(HttpRequest.BodyPublishers.ofString(encode(form), StandardCharsets.UTF_8))
                .build();
        try {
            HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
            JsonObject body = objectIn(response.body());
            if (response.statusCode() / 100 == 2 && body.has("access_token")) {
                return body;
            }
            throw failure(response.statusCode(), body);
        } catch (IOException e) {
            throw new ProviderException(ProviderException.Kind.NETWORK,
                    "Could not reach Google's token endpoint: " + e.getMessage(), e);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new ProviderException(ProviderException.Kind.NETWORK, "Interrupted while signing in to Google", e);
        }
    }

    private static ProviderException failure(int status, JsonObject body) {
        String code = text(body, "error");
        String detail = text(body, "error_description");
        ProviderException.Kind kind;
        if ("invalid_grant".equals(code) || "invalid_client".equals(code) || status == 401 || status == 403) {
            // invalid_grant is what a revoked or expired refresh token looks
            // like, and it is the one case where signing in again is the fix.
            kind = ProviderException.Kind.AUTHENTICATION;
        } else if (status >= 500) {
            kind = ProviderException.Kind.SERVER;
        } else if (status == 429) {
            kind = ProviderException.Kind.RATE_LIMITED;
        } else {
            kind = ProviderException.Kind.PROTOCOL;
        }
        return new ProviderException(kind, "Google refused the sign-in (HTTP " + status + ", "
                + (code == null ? "no details" : code) + (detail == null ? "" : ": " + detail) + ")");
    }

    // -----------------------------------------------------------------------
    // The token on disk
    // -----------------------------------------------------------------------

    private void load() {
        if (loaded) {
            return;
        }
        loaded = true;
        JsonFile file = new JsonFile(tokenFile.get());
        JsonElement stored = file.readTree().orElse(null);
        if (stored == null || !stored.isJsonObject()) {
            return;
        }
        try {
            StoredSignIn signIn = file.gson().fromJson(stored, StoredSignIn.class);
            // A refresh token belongs to the OAuth client that minted it. If the
            // player has pasted a different client id, this one is dead weight.
            if (signIn == null || !clientId.equals(signIn.clientId())) {
                return;
            }
            refreshToken = signIn.refreshToken() == null || signIn.refreshToken().isBlank()
                    ? null
                    : signIn.refreshToken();
            account = signIn.account() == null ? "" : signIn.account();
        } catch (RuntimeException e) {
            log.warn("Could not read the Google Photos sign-in in {}; signing in again", file.path(), e);
        }
    }

    private void store() {
        JsonFile file = new JsonFile(tokenFile.get());
        try {
            file.write(file.gson().toJsonTree(new StoredSignIn(clientId, refreshToken, account)));
        } catch (IOException e) {
            // The sign-in still works for this session. It just will not survive
            // a restart, which is worth a warning and not worth failing over.
            log.warn("Could not save the Google Photos sign-in to {}", file.path(), e);
        }
    }

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

    private String randomToken(int bytes) {
        byte[] value = new byte[bytes];
        random.nextBytes(value);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(value);
    }

    private static String challengeFor(String verifier) {
        try {
            MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
            return Base64.getUrlEncoder().withoutPadding()
                    .encodeToString(sha256.digest(verifier.getBytes(StandardCharsets.US_ASCII)));
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("Every JVM is required to provide SHA-256", e);
        }
    }

    /**
     * The email claim out of an OpenID token, or null.
     *
     * <p>Read rather than verified: this is Google's own answer, arriving over
     * TLS from Google's own token endpoint, and it is used for one thing --
     * putting a name next to "Test connection". Nothing is authorised by it.
     */
    private static String emailIn(String idToken) {
        if (idToken == null) {
            return null;
        }
        String[] parts = idToken.split("\\.");
        if (parts.length < 2) {
            return null;
        }
        try {
            JsonObject claims = objectIn(new String(
                    Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8));
            return text(claims, "email");
        } catch (RuntimeException e) {
            log.debug("Could not read the account name out of Google's id token: {}", e.toString());
            return null;
        }
    }

    /** Whatever object is in {@code body}, or an empty one -- an unparseable answer is a missing answer. */
    private static JsonObject objectIn(String body) {
        if (body == null || body.isBlank()) {
            return new JsonObject();
        }
        JsonElement parsed = JsonParser.parseString(body);
        return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
    }

    private static String text(JsonObject body, String member) {
        JsonElement value = body.get(member);
        return value == null || !value.isJsonPrimitive() ? null : value.getAsString();
    }

    private static String encode(Map<String, String> values) {
        StringJoiner joiner = new StringJoiner("&");
        values.forEach((name, value) -> joiner.add(
                URLEncoder.encode(name, StandardCharsets.UTF_8) + "="
                        + URLEncoder.encode(value, StandardCharsets.UTF_8)));
        return joiner.toString();
    }
}
