package org.whoneedhelp.mobile;

import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

final class FixtureHttpServer implements Closeable {
    static final class RecordedRequest {
        final String method;
        final String path;
        final String body;

        RecordedRequest(String method, String path, String body) {
            this.method = method;
            this.path = path;
            this.body = body;
        }
    }

    private final ServerSocket serverSocket;
    private final ExecutorService executor = Executors.newCachedThreadPool();
    private final List<RecordedRequest> requests =
        Collections.synchronizedList(new ArrayList<>());
    private volatile boolean running = true;
    private volatile boolean disconnectStopRequest;
    private volatile String disconnectNextGetPath;

    FixtureHttpServer() throws IOException {
        URI base = URI.create(BuildConfig.BASE_URL);

        if (
            !"http".equals(base.getScheme())
                || !"127.0.0.1".equals(base.getHost())
                || base.getPort() < 1
        ) {
            throw new IllegalStateException(
                "Instrumentation BASE_URL must be an isolated http://127.0.0.1:PORT origin"
            );
        }

        serverSocket = new ServerSocket();
        serverSocket.setReuseAddress(true);
        serverSocket.bind(
            new InetSocketAddress(InetAddress.getByName(base.getHost()), base.getPort())
        );
        executor.execute(this::acceptLoop);
    }

    void disconnectStopRequest() {
        disconnectStopRequest = true;
    }

    void disconnectNextGet(String path) {
        disconnectNextGetPath = path;
    }

    RecordedRequest awaitRequestEndingWith(String suffix, long timeout, TimeUnit unit)
        throws InterruptedException {
        return awaitRequestAfterCount(suffix, 0, timeout, unit);
    }

    int requestCountEndingWith(String suffix) {
        int count = 0;

        synchronized (requests) {
            for (RecordedRequest request : requests) {
                if (request.path.endsWith(suffix)) {
                    count++;
                }
            }
        }

        return count;
    }

    RecordedRequest awaitRequestAfterCount(
        String suffix,
        int previousCount,
        long timeout,
        TimeUnit unit
    ) throws InterruptedException {
        long deadline = System.nanoTime() + unit.toNanos(timeout);

        while (System.nanoTime() < deadline) {
            int count = 0;

            synchronized (requests) {
                for (RecordedRequest request : requests) {
                    if (request.path.endsWith(suffix)) {
                        count++;

                        if (count > previousCount) {
                            return request;
                        }
                    }
                }
            }

            Thread.sleep(50);
        }

        return null;
    }

    @Override
    public void close() throws IOException {
        running = false;
        serverSocket.close();
        executor.shutdownNow();

        try {
            executor.awaitTermination(5, TimeUnit.SECONDS);
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
        }
    }

    private void acceptLoop() {
        while (running) {
            try {
                Socket socket = serverSocket.accept();
                executor.execute(() -> handle(socket));
            } catch (IOException exception) {
                if (running) {
                    throw new IllegalStateException("Fixture HTTP accept failed", exception);
                }
            }
        }
    }

    private void handle(Socket socket) {
        try (Socket connection = socket) {
            connection.setSoTimeout(5_000);
            BufferedInputStream input = new BufferedInputStream(connection.getInputStream());
            String requestLine = readLine(input);

            if (requestLine == null || requestLine.trim().isEmpty()) {
                return;
            }

            String[] parts = requestLine.split(" ", 3);

            if (parts.length != 3) {
                return;
            }

            int contentLength = 0;
            String header;

            while ((header = readLine(input)) != null && !header.isEmpty()) {
                String lower = header.toLowerCase(Locale.ROOT);

                if (lower.startsWith("content-length:")) {
                    contentLength = Integer.parseInt(
                        header.substring(header.indexOf(':') + 1).trim()
                    );
                }
            }

            byte[] bodyBytes = readExactly(input, contentLength);
            RecordedRequest request = new RecordedRequest(
                parts[0],
                parts[1],
                new String(bodyBytes, StandardCharsets.UTF_8)
            );
            requests.add(request);

            if (disconnectStopRequest && request.path.endsWith("/stop")) {
                return;
            }

            if ("GET".equals(request.method) && request.path.equals(disconnectNextGetPath)) {
                disconnectNextGetPath = null;
                return;
            }

            if ("GET".equals(request.method)) {
                writeResponse(
                    connection.getOutputStream(),
                    200,
                    "text/html; charset=utf-8",
                    fixtureHtml(request.path).getBytes(StandardCharsets.UTF_8)
                );
            } else {
                writeResponse(
                    connection.getOutputStream(),
                    204,
                    "application/json",
                    new byte[0]
                );
            }
        } catch (IOException ignored) {
            // A deliberately disconnected request is part of the failure test.
        }
    }

    private static String fixtureHtml(String path) {
        String escapedPath = path
            .replace("&", "&amp;")
            .replace("<", "&lt;")
            .replace(">", "&gt;")
            .replace("\"", "&quot;");

        return "<!doctype html><html><head>"
            + "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
            + "<title>Who Need Help Android fixture</title></head><body>"
            + "<h1 id=\"marker\">loaded:" + escapedPath + "</h1>"
            + "<button id=\"location\" onclick=\"navigator.geolocation.getCurrentPosition("
            + "()=>{document.getElementById('marker').textContent='location-granted';"
            + "fetch('/geolocation-granted')},"
            + "()=>{document.getElementById('marker').textContent='location-denied';"
            + "fetch('/geolocation-denied')},"
            + "{enableHighAccuracy:true,maximumAge:0,timeout:10000})\">"
            + "Request location</button>"
            + "</body></html>";
    }

    private static String readLine(BufferedInputStream input) throws IOException {
        ByteArrayOutputStream line = new ByteArrayOutputStream();
        int value;

        while ((value = input.read()) != -1) {
            if (value == '\n') {
                break;
            }

            if (value != '\r') {
                line.write(value);
            }
        }

        if (value == -1 && line.size() == 0) {
            return null;
        }

        return new String(line.toByteArray(), StandardCharsets.US_ASCII);
    }

    private static byte[] readExactly(BufferedInputStream input, int length)
        throws IOException {
        byte[] result = new byte[length];
        int offset = 0;

        while (offset < length) {
            int count = input.read(result, offset, length - offset);

            if (count == -1) {
                throw new IOException("Unexpected end of HTTP request body");
            }

            offset += count;
        }

        return result;
    }

    private static void writeResponse(
        OutputStream output,
        int status,
        String contentType,
        byte[] body
    ) throws IOException {
        String reason = status == 200 ? "OK" : "No Content";
        String headers = "HTTP/1.1 " + status + " " + reason + "\r\n"
            + "Content-Type: " + contentType + "\r\n"
            + "Content-Length: " + body.length + "\r\n"
            + "Connection: close\r\n\r\n";
        output.write(headers.getBytes(StandardCharsets.US_ASCII));
        output.write(body);
        output.flush();
    }
}
