package dev.photosync.core.provider.google;

import lombok.extern.slf4j.Slf4j;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * The other half of Google's redirect: a socket on the loopback interface that
 * waits for the browser to come back with an authorization code.
 *
 * <p>A raw {@link ServerSocket} rather than {@code com.sun.net.httpserver}.
 * Minecraft ships its own trimmed Java runtime, {@code jdk.httpserver} is not a
 * module we can prove is in it on every launcher, and a sign-in that throws
 * {@code NoClassDefFoundError} on somebody else's machine is not worth the
 * convenience. One request line and one fixed response is all this needs.
 *
 * <p>Google allows a desktop client to redirect to {@code 127.0.0.1} on any
 * port without registering it, which is why the port can be whatever the OS
 * hands out.
 */
@Slf4j
final class LoopbackReceiver implements AutoCloseable {

    /** Long enough for a browser that is still starting up, short enough not to hang a worker. */
    private static final int READ_TIMEOUT_MILLIS = 10_000;

    private final ServerSocket socket;

    LoopbackReceiver() throws IOException {
        // Loopback only, and a backlog of one: this listens for a single
        // redirect from the player's own browser and nothing else.
        this.socket = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"));
    }

    /** What to hand Google as {@code redirect_uri}. It must match at token exchange, byte for byte. */
    String redirectUri() {
        return "http://127.0.0.1:" + socket.getLocalPort();
    }

    /**
     * Blocks until the browser arrives with a query string, and answers it with
     * a page telling the player to go back to the game.
     *
     * <p>Browsers open speculative connections and ask for {@code favicon.ico}
     * unprompted, so anything without a {@code code} or {@code error} is turned
     * away and the wait continues until the deadline.
     */
    Map<String, String> awaitRedirect(Duration timeout) throws IOException {
        long deadline = System.currentTimeMillis() + timeout.toMillis();
        while (true) {
            long remaining = deadline - System.currentTimeMillis();
            if (remaining <= 0) {
                throw new SocketTimeoutException("Nothing came back from the browser");
            }
            socket.setSoTimeout((int) Math.min(remaining, Integer.MAX_VALUE));
            try (Socket client = socket.accept()) {
                client.setSoTimeout(READ_TIMEOUT_MILLIS);
                Map<String, String> parameters = read(client);
                if (parameters.containsKey("code") || parameters.containsKey("error")) {
                    respond(client, parameters.containsKey("code"));
                    return parameters;
                }
                respond(client, false);
            } catch (SocketTimeoutException e) {
                throw e;
            } catch (IOException e) {
                // One browser connection going wrong is not the sign-in going
                // wrong; the real one may still be on its way.
                log.debug("Ignoring a loopback connection that failed: {}", e.toString());
            }
        }
    }

    private Map<String, String> read(Socket client) throws IOException {
        BufferedReader reader = new BufferedReader(
                new InputStreamReader(client.getInputStream(), StandardCharsets.ISO_8859_1));
        String requestLine = reader.readLine();
        if (requestLine == null) {
            return Map.of();
        }
        String[] parts = requestLine.split(" ");
        int query = parts.length < 2 ? -1 : parts[1].indexOf('?');
        return query < 0 ? Map.of() : parse(parts[1].substring(query + 1));
    }

    private static Map<String, String> parse(String query) {
        Map<String, String> parameters = new LinkedHashMap<>();
        for (String pair : query.split("&")) {
            if (pair.isEmpty()) {
                continue;
            }
            int equals = pair.indexOf('=');
            String name = equals < 0 ? pair : pair.substring(0, equals);
            String value = equals < 0 ? "" : pair.substring(equals + 1);
            parameters.put(decode(name), decode(value));
        }
        return parameters;
    }

    private static String decode(String value) {
        try {
            return URLDecoder.decode(value, StandardCharsets.UTF_8);
        } catch (IllegalArgumentException e) {
            return value;
        }
    }

    /**
     * The page the player is left looking at. Deliberately plain text in a
     * minimal document: it has to render from a socket that is about to close,
     * with no stylesheet and no second request.
     */
    private static void respond(Socket client, boolean signedIn) throws IOException {
        String message = signedIn
                ? "PhotoSync is signed in. You can close this tab and go back to Minecraft."
                : "PhotoSync is still waiting for Google's answer. You can close this tab.";
        byte[] page = ("<!doctype html><meta charset=\"utf-8\"><title>PhotoSync</title>"
                + "<body style=\"font-family:sans-serif;margin:3rem\"><p>" + message + "</p>")
                .getBytes(StandardCharsets.UTF_8);
        OutputStream out = client.getOutputStream();
        out.write(("HTTP/1.1 200 OK\r\n"
                + "Content-Type: text/html; charset=utf-8\r\n"
                + "Content-Length: " + page.length + "\r\n"
                + "Connection: close\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
        out.write(page);
        out.flush();
    }

    @Override
    public void close() {
        try {
            socket.close();
        } catch (IOException e) {
            log.debug("Closing the sign-in socket threw", e);
        }
    }
}
