package p.studio.execution;

import org.junit.jupiter.api.Test;
import p.studio.StudioBackgroundTasks;
import p.studio.debug.runtime.StudioRuntimeDebugConnectionSettings;
import p.studio.debug.runtime.StudioRuntimeHandshakeClient;
import p.studio.debug.runtime.StudioRuntimeHandshakeResult;
import p.studio.execution.runtime.StudioRuntimeProcessHandle;
import p.studio.projects.ProjectReference;
import p.studio.projectstate.ProjectLocalStudioSetup;
import p.studio.shipper.StudioShipperPreparationStatus;
import p.studio.shipper.StudioShipperPrepareResult;
import p.studio.workspaces.WorkspaceId;

import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

import static org.junit.jupiter.api.Assertions.*;

final class StudioPlayStopCoordinatorTest {
    @Test
    void playNavigatesToDebugAndStopsOnPreparationFailure() throws Exception {
        final StudioExecutionSessionService session = new StudioExecutionSessionService();
        final AtomicReference<WorkspaceId> selectedWorkspace = new AtomicReference<>();
        final AtomicInteger launcherCalls = new AtomicInteger();
        final StudioBackgroundTasks backgroundTasks = new StudioBackgroundTasks(Executors.newCachedThreadPool());
        try {
            final StudioPlayStopCoordinator coordinator = new StudioPlayStopCoordinator(
                    projectReference(),
                    new ProjectLocalStudioSetup("/bin/echo", ProjectLocalStudioSetup.EditorIndentationSetup.defaults()),
                    session,
                    (project, sink) -> new StudioShipperPrepareResult(StudioShipperPreparationStatus.FAILED, List.of(), null, null, null, null),
                    new RecordingHandshakeClient(),
                    (projectReference, runtimePath) -> {
                        launcherCalls.incrementAndGet();
                        return new FakeRuntimeProcessHandle();
                    },
                    backgroundTasks,
                    selectedWorkspace::set,
                    ignored -> { },
                    false);

            coordinator.play();

            assertEquals(WorkspaceId.DEBUG, selectedWorkspace.get());
            assertTrue(waitForState(session, StudioExecutionState.PREPARE_FAILED));
            assertEquals(0, launcherCalls.get());
        } finally {
            backgroundTasks.shutdown();
        }
    }

    @Test
    void repeatedPlayIsIgnoredWhileRuntimeIsActiveAndStopKillsProcess() throws Exception {
        final StudioExecutionSessionService session = new StudioExecutionSessionService();
        final ArrayList<WorkspaceId> selected = new ArrayList<>();
        final AtomicInteger launches = new AtomicInteger();
        final RecordingHandshakeClient handshakeClient = new RecordingHandshakeClient();
        final FakeRuntimeProcessHandle processHandle = new FakeRuntimeProcessHandle();
        final StudioBackgroundTasks backgroundTasks = new StudioBackgroundTasks(Executors.newCachedThreadPool());
        try {
            final StudioPlayStopCoordinator coordinator = new StudioPlayStopCoordinator(
                    projectReference(),
                    new ProjectLocalStudioSetup("/bin/echo", ProjectLocalStudioSetup.EditorIndentationSetup.defaults()),
                    session,
                    (project, sink) -> new StudioShipperPrepareResult(StudioShipperPreparationStatus.SUCCESS, List.of(), null, null, null, null),
                    handshakeClient,
                    (projectReference, runtimePath) -> {
                        launches.incrementAndGet();
                        return processHandle;
                    },
                    backgroundTasks,
                    selected::add,
                    ignored -> { },
                    false);

            coordinator.play();
            assertTrue(waitForState(session, StudioExecutionState.RUNNING), () -> "State was: " + session.snapshot().state() + " Logs: " + session.snapshot().logs());

            coordinator.play();
            assertEquals(1, launches.get());

            coordinator.stop();
            assertTrue(waitForState(session, StudioExecutionState.STOPPED));
            assertTrue(processHandle.destroyed);
            assertEquals(1, handshakeClient.disconnectCalls.get());
            assertFalse(selected.isEmpty());
        } finally {
            backgroundTasks.shutdown();
        }
    }

    @Test
    void stopDuringPreparingIsANoOp() throws Exception {
        final StudioExecutionSessionService session = new StudioExecutionSessionService();
        final CountDownLatch started = new CountDownLatch(1);
        final CountDownLatch release = new CountDownLatch(1);
        final StudioBackgroundTasks backgroundTasks = new StudioBackgroundTasks(Executors.newCachedThreadPool());
        try {
            final StudioPlayStopCoordinator coordinator = new StudioPlayStopCoordinator(
                    projectReference(),
                    new ProjectLocalStudioSetup("/bin/echo", ProjectLocalStudioSetup.EditorIndentationSetup.defaults()),
                    session,
                    (project, sink) -> {
                        started.countDown();
                        await(release);
                        return new StudioShipperPrepareResult(StudioShipperPreparationStatus.FAILED, List.of(), null, null, null, null);
                    },
                    new RecordingHandshakeClient(),
                    (projectReference, runtimePath) -> new FakeRuntimeProcessHandle(),
                    backgroundTasks,
                    ignored -> { },
                    ignored -> { },
                    false);

            coordinator.play();
            assertTrue(started.await(2, TimeUnit.SECONDS));
            assertEquals(StudioExecutionState.PREPARING, session.snapshot().state());

            coordinator.stop();
            assertEquals(StudioExecutionState.PREPARING, session.snapshot().state());

            release.countDown();
            assertTrue(waitForState(session, StudioExecutionState.PREPARE_FAILED));
        } finally {
            backgroundTasks.shutdown();
        }
    }

    @Test
    void spawnFailureBecomesRuntimeFailed() throws Exception {
        final StudioExecutionSessionService session = new StudioExecutionSessionService();
        final StudioBackgroundTasks backgroundTasks = new StudioBackgroundTasks(Executors.newCachedThreadPool());
        try {
            final StudioPlayStopCoordinator coordinator = new StudioPlayStopCoordinator(
                    projectReference(),
                    new ProjectLocalStudioSetup("/definitely/missing/runtime", ProjectLocalStudioSetup.EditorIndentationSetup.defaults()),
                    session,
                    (project, sink) -> new StudioShipperPrepareResult(StudioShipperPreparationStatus.SUCCESS, List.of(), null, null, null, null),
                    new RecordingHandshakeClient(),
                    (projectReference, runtimePath) -> { throw new java.io.IOException("mock spawn failure"); },
                    backgroundTasks,
                    ignored -> { },
                    ignored -> { },
                    false);

            coordinator.play();

            assertTrue(waitForState(session, StudioExecutionState.RUNTIME_FAILED));
            assertTrue(session.snapshot().logs().stream().anyMatch(entry -> entry.message().contains("Runtime preflight failed")), () -> "Logs: " + session.snapshot().logs());
        } finally {
            backgroundTasks.shutdown();
        }
    }

    private boolean waitForState(
            final StudioExecutionSessionService session,
            final StudioExecutionState expected) throws InterruptedException {
        for (int attempt = 0; attempt < 40; attempt += 1) {
            if (session.snapshot().state() == expected) {
                return true;
            }
            Thread.sleep(50L);
        }
        return false;
    }

    private void await(final CountDownLatch latch) {
        try {
            latch.await(2, TimeUnit.SECONDS);
        } catch (InterruptedException interruptedException) {
            Thread.currentThread().interrupt();
        }
    }

    private ProjectReference projectReference() {
        return new ProjectReference("Main", "1.0.0", "pbs", 1, Path.of("/tmp/prometeu-play-stop"));
    }

    private static final class RecordingHandshakeClient implements StudioRuntimeHandshakeClient {
        private final AtomicInteger disconnectCalls = new AtomicInteger();

        @Override
        public StudioRuntimeHandshakeResult connect(final StudioExecutionSessionService session) {
            throw new UnsupportedOperationException();
        }

        @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) {
            session.transitionToRunning();
            session.appendRuntimeLog(StudioExecutionLogSeverity.INFO, "Fake runtime handshake connected.");
            return StudioRuntimeHandshakeResult.success(settings, 1, "test");
        }

        @Override
        public void disconnect(final StudioExecutionSessionService session) {
            disconnectCalls.incrementAndGet();
            if (session.snapshot().state() == StudioExecutionState.CONNECTING || session.snapshot().state() == StudioExecutionState.RUNNING) {
                session.transitionToStopped();
            }
        }
    }

    private static final class FakeRuntimeProcessHandle implements StudioRuntimeProcessHandle {
        private final CompletableFuture<Integer> exit = new CompletableFuture<>();
        private boolean destroyed;

        @Override
        public InputStream stdout() {
            return new ByteArrayInputStream(new byte[0]);
        }

        @Override
        public InputStream stderr() {
            return new ByteArrayInputStream(new byte[0]);
        }

        @Override
        public boolean isAlive() {
            return !destroyed;
        }

        @Override
        public void destroyForcibly() {
            destroyed = true;
            exit.complete(137);
        }

        @Override
        public CompletableFuture<Integer> onExit() {
            return exit;
        }
    }
}
