package p.studio.debug.runtime;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import p.studio.Container;
import p.studio.StudioBackgroundTasks;
import p.studio.execution.StudioExecutionLogSeverity;
import p.studio.execution.StudioExecutionSessionService;
import p.studio.execution.StudioExecutionState;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.concurrent.Future;

public final class StudioRuntimeHandshakeService implements StudioRuntimeHandshakeClient {
    private final ObjectMapper mapper;
    private final StudioBackgroundTasks backgroundTasks;
    private final Object monitor = new Object();
    private Socket socket;
    private BufferedReader reader;
    private BufferedWriter writer;
    private Future<?> receiverTask;
    private boolean intentionalDisconnect;

    public StudioRuntimeHandshakeService() {
        this(Container.mapper(), Container.backgroundTasks());
    }

    public StudioRuntimeHandshakeService(
            final ObjectMapper mapper,
            final StudioBackgroundTasks backgroundTasks) {
        this.mapper = Objects.requireNonNull(mapper, "mapper");
        this.backgroundTasks = Objects.requireNonNull(backgroundTasks, "backgroundTasks");
    }

    public StudioRuntimeHandshakeResult connect(final StudioExecutionSessionService session) {
        return connectWithRetry(session, StudioRuntimeDebugConnectionSettings.defaults(), 1, 0L);
    }

    @Override
    public StudioRuntimeHandshakeResult connect(
            final StudioExecutionSessionService session,
            final StudioRuntimeDebugConnectionSettings settings) {
        return connectWithRetry(session, settings, 1, 0L);
    }

    @Override
    public StudioRuntimeHandshakeResult connectWithRetry(
            final StudioExecutionSessionService session,
            final StudioRuntimeDebugConnectionSettings settings,
            final int maxAttempts,
            final long retryDelayMillis) {
        final StudioExecutionSessionService safeSession = Objects.requireNonNull(session, "session");
        final StudioRuntimeDebugConnectionSettings safeSettings = Objects.requireNonNull(settings, "settings");
        safeSession.transitionToConnecting();
        final int attempts = Math.max(1, maxAttempts);
        Exception failure = null;
        for (int attempt = 1; attempt <= attempts; attempt += 1) {
            try {
                synchronized (monitor) {
                    cleanupLocked();
                    intentionalDisconnect = false;
                    socket = new Socket();
                    socket.connect(new InetSocketAddress(safeSettings.host(), safeSettings.port()), 5_000);
                    socket.setSoTimeout(5_000);
                    reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
                    writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8));
                }

                final String handshakeLine = readHandshakeLine();
                final StudioRuntimeHandshakeRuntimeToClient handshake = mapper.readValue(
                        handshakeLine,
                        StudioRuntimeHandshakeRuntimeToClient.class);
                writeMessage(mapper.writeValueAsString(new StudioRuntimeHandshakeClientToRuntime("start")));
                synchronized (monitor) {
                    if (socket != null) {
                        socket.setSoTimeout(0);
                    }
                    receiverTask = backgroundTasks.submit(() -> receiveLoop(safeSession));
                }

                safeSession.appendRuntimeLog(
                        StudioExecutionLogSeverity.INFO,
                        "Runtime handshake connected to %s:%d".formatted(safeSettings.host(), safeSettings.port()));
                safeSession.appendRuntimeLog(
                        StudioExecutionLogSeverity.INFO,
                        "Runtime protocol %d (%s)".formatted(handshake.protocolVersion(), handshake.runtimeVersion()));
                safeSession.transitionToRunning();
                return StudioRuntimeHandshakeResult.success(safeSettings, handshake.protocolVersion(), handshake.runtimeVersion());
            } catch (Exception exception) {
                failure = exception;
                cleanup();
                if (attempt < attempts && retryDelayMillis > 0L) {
                    sleepQuietly(retryDelayMillis);
                }
            }
        }
        safeSession.appendRuntimeLog(
                StudioExecutionLogSeverity.ERROR,
                "Runtime handshake failed: " + failureMessage(failure == null ? new IOException("unknown handshake failure") : failure));
        safeSession.transitionToRuntimeFailed();
        return StudioRuntimeHandshakeResult.failure(safeSettings, failureMessage(failure == null ? new IOException("unknown handshake failure") : failure));
    }

    @Override
    public void disconnect(final StudioExecutionSessionService session) {
        final StudioExecutionSessionService safeSession = Objects.requireNonNull(session, "session");
        final boolean wasConnected;
        synchronized (monitor) {
            wasConnected = socket != null;
            intentionalDisconnect = true;
            cleanupLocked();
        }
        if (wasConnected) {
            safeSession.appendRuntimeLog(StudioExecutionLogSeverity.INFO, "Runtime debug connection disconnected.");
            final StudioExecutionState currentState = safeSession.snapshot().state();
            if (currentState == StudioExecutionState.CONNECTING || currentState == StudioExecutionState.RUNNING) {
                safeSession.transitionToStopped();
            }
        }
    }

    private void receiveLoop(final StudioExecutionSessionService session) {
        try {
            String line;
            while ((line = readRuntimeLine()) != null) {
                processRuntimeLine(session, line);
            }
            handleUnexpectedDisconnect(session, "Runtime debug connection closed.");
        } catch (IOException ioException) {
            handleUnexpectedDisconnect(session, "Runtime debug connection lost: " + failureMessage(ioException));
        }
    }

    private void processRuntimeLine(
            final StudioExecutionSessionService session,
            final String line) throws IOException {
        final JsonNode node = mapper.readTree(line);
        if (node == null || !node.has("event")) {
            return;
        }
        if ("log".equals(node.get("event").asText())) {
            final StudioRuntimeLogEvent event = mapper.treeToValue(node, StudioRuntimeLogEvent.class);
            session.appendRuntimeLog(
                    severityOf(event.level()),
                    renderRuntimeMessage(event));
        }
    }

    private String renderRuntimeMessage(final StudioRuntimeLogEvent event) {
        if (event.source() == null || event.source().isBlank()) {
            return event.message();
        }
        return "[%s] %s".formatted(event.source(), event.message());
    }

    private StudioExecutionLogSeverity severityOf(final String level) {
        if (level == null) {
            return StudioExecutionLogSeverity.INFO;
        }
        return switch (level.trim().toUpperCase()) {
            case "ERROR", "FATAL" -> StudioExecutionLogSeverity.ERROR;
            default -> StudioExecutionLogSeverity.INFO;
        };
    }

    private String readHandshakeLine() throws IOException {
        synchronized (monitor) {
            if (reader == null) {
                throw new IOException("runtime debug reader not available");
            }
            final String line = reader.readLine();
            if (line == null) {
                throw new IOException("connection closed during handshake");
            }
            return line;
        }
    }

    private String readRuntimeLine() throws IOException {
        synchronized (monitor) {
            if (reader == null) {
                return null;
            }
            return reader.readLine();
        }
    }

    private void writeMessage(final String message) throws IOException {
        synchronized (monitor) {
            if (writer == null) {
                throw new IOException("runtime debug writer not available");
            }
            writer.write(message);
            writer.newLine();
            writer.flush();
        }
    }

    private void handleUnexpectedDisconnect(
            final StudioExecutionSessionService session,
            final String message) {
        final boolean shouldReport;
        synchronized (monitor) {
            shouldReport = !intentionalDisconnect;
            cleanupLocked();
        }
        if (!shouldReport) {
            return;
        }
        session.appendRuntimeLog(StudioExecutionLogSeverity.ERROR, message);
        final StudioExecutionState currentState = session.snapshot().state();
        if (currentState == StudioExecutionState.CONNECTING || currentState == StudioExecutionState.RUNNING) {
            session.transitionToRuntimeFailed();
        }
    }

    private void cleanup() {
        synchronized (monitor) {
            cleanupLocked();
        }
    }

    private void cleanupLocked() {
        if (receiverTask != null) {
            receiverTask.cancel(true);
            receiverTask = null;
        }
        closeQuietly(reader);
        closeQuietly(writer);
        closeQuietly(socket);
        reader = null;
        writer = null;
        socket = null;
    }

    private void closeQuietly(final AutoCloseable closeable) {
        if (closeable == null) {
            return;
        }
        try {
            closeable.close();
        } catch (Exception ignored) {
        }
    }

    private String failureMessage(final Exception exception) {
        final String message = exception.getMessage();
        return message == null || message.isBlank()
                ? exception.getClass().getSimpleName()
                : message;
    }

    private void sleepQuietly(final long retryDelayMillis) {
        try {
            Thread.sleep(retryDelayMillis);
        } catch (InterruptedException interruptedException) {
            Thread.currentThread().interrupt();
        }
    }
}
