package com.ruoyi.generator.service.front;

import com.ruoyi.generator.domain.front.FrontProject;
import com.ruoyi.generator.domain.front.dto.ProjectRunPreviewStatus;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.Field;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.times;

public class FrontProjectRunPreviewServiceImplTest
{
    @Rule
    public TemporaryFolder temporaryFolder = new TemporaryFolder();

    private FrontProjectRunPreviewServiceImpl runPreviewService;

    @Mock
    private IFrontProjectService frontProjectService;
    @Mock
    private IFrontProjectPreviewService frontProjectPreviewService;
    @Mock
    private RunPreviewPortAllocator portAllocator;
    @Mock
    private RunPreviewDatabaseInitializer databaseInitializer;
    @Mock
    private RunPreviewProcessRunner processRunner;

    private RecordingProcessKiller processKiller;
    private FakeProcess backendProcess;
    private FakeProcess frontendProcess;
    private FakeProcess adminFrontendProcess;

    @Before
    public void setUp() throws Exception
    {
        MockitoAnnotations.initMocks(this);
        runPreviewService = new FrontProjectRunPreviewServiceImpl();
        processKiller = new RecordingProcessKiller();
        backendProcess = new FakeProcess();
        frontendProcess = new FakeProcess();
        adminFrontendProcess = new FakeProcess();

        setField("frontProjectService", frontProjectService);
        setField("frontProjectPreviewService", frontProjectPreviewService);
        setField("portAllocator", portAllocator);
        setField("databaseInitializer", databaseInitializer);
        setField("processRunner", processRunner);
        setField("processKiller", processKiller);
        setField("workspaceRoot", temporaryFolder.newFolder("preview-workspaces").getAbsolutePath());
        setField("mysqlHost", "127.0.0.1");
        setField("mysqlPort", 3306);
        setField("mysqlUsername", "root");
        setField("mysqlPassword", "123456");
    }

    @Test
    public void startCreatesWorkspaceInitializesDatabaseAndStartsProcesses() throws Exception
    {
        when(frontProjectService.getProject(10L, 20L)).thenReturn(project());
        when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithGeneratedProject());
        when(portAllocator.allocate()).thenReturn(18080, 18081);
        when(databaseInitializer.initialize(any(File.class), anyString(), eq("127.0.0.1"), eq(3306), eq("root"), eq("123456")))
                .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test");
        whenBackendStarts();
        when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess);

        ProjectRunPreviewStatus status = runPreviewService.start(10L, 20L);

        assertEquals("STARTING", status.getStatus());
        assertEquals("http://127.0.0.1:18080", status.getBackendUrl());
        assertEquals("http://127.0.0.1:18081", status.getFrontendUrl());
        assertTrue(new File(status.getWorkspacePath(), "pom.xml").isFile());
        assertTrue(new File(status.getWorkspacePath(), "vue/package.json").isFile());

        ArgumentCaptor<File> sqlDirectoryCaptor = ArgumentCaptor.forClass(File.class);
        ArgumentCaptor<String> databaseNameCaptor = ArgumentCaptor.forClass(String.class);
        verify(databaseInitializer).initialize(sqlDirectoryCaptor.capture(), databaseNameCaptor.capture(), eq("127.0.0.1"), eq(3306), eq("root"), eq("123456"));
        assertEquals("sql", sqlDirectoryCaptor.getValue().getName());
        assertTrue(databaseNameCaptor.getValue().startsWith("preview_20_"));

        ArgumentCaptor<Map> backendEnvCaptor = ArgumentCaptor.forClass(Map.class);
        verify(processRunner).startBackend(any(File.class), any(File.class), any(File.class), anyString(), any(File.class), backendEnvCaptor.capture());
        assertEquals("18080", backendEnvCaptor.getValue().get("SERVER_PORT"));
        assertEquals("jdbc:mysql://127.0.0.1:3306/preview_20_test", backendEnvCaptor.getValue().get("DB_URL"));
        assertEquals("root", backendEnvCaptor.getValue().get("DB_USERNAME"));
        assertEquals("123456", backendEnvCaptor.getValue().get("DB_PASSWORD"));
        assertTrue(((String) backendEnvCaptor.getValue().get("JAVA_HOME")).length() > 0);
        assertTrue(((String) backendEnvCaptor.getValue().get("MAVEN_OPTS")).contains("java.io.tmpdir"));
        assertTrue(((String) backendEnvCaptor.getValue().get("MAVEN_OPTS")).contains("java-tmp"));
        assertTrue(((String) backendEnvCaptor.getValue().get("JAVA_TOOL_OPTIONS")).contains("java.io.tmpdir"));

        ArgumentCaptor<Map> frontendEnvCaptor = ArgumentCaptor.forClass(Map.class);
        verify(processRunner).startFrontend(any(File.class), frontendEnvCaptor.capture());
        assertEquals("18081", frontendEnvCaptor.getValue().get("PORT"));
        assertEquals("http://127.0.0.1:18080", frontendEnvCaptor.getValue().get("VUE_APP_API_BASE_URL"));
        String frontendCache = (String) frontendEnvCaptor.getValue().get("NPM_CONFIG_CACHE");
        assertEquals(".npm-cache", new File(frontendCache).getName());
        assertEquals(new File(status.getWorkspacePath()).getParentFile().getCanonicalFile(),
                new File(frontendCache).getParentFile().getCanonicalFile());
        assertEquals(frontendCache, frontendEnvCaptor.getValue().get("npm_config_cache"));
        assertEquals("false", frontendEnvCaptor.getValue().get("NPM_CONFIG_UPDATE_NOTIFIER"));
        assertEquals("1", frontendEnvCaptor.getValue().get("NO_UPDATE_NOTIFIER"));
        assertEquals("false", frontendEnvCaptor.getValue().get("NPM_CONFIG_OFFLINE"));
        assertEquals("true", frontendEnvCaptor.getValue().get("NPM_CONFIG_PREFER_OFFLINE"));
        assertEquals("false", frontendEnvCaptor.getValue().get("NPM_CONFIG_PREFER_ONLINE"));
        assertEquals("", frontendEnvCaptor.getValue().get("NPM_CONFIG_PROXY"));
        assertEquals("", frontendEnvCaptor.getValue().get("NPM_CONFIG_HTTPS_PROXY"));
        assertEquals("*", frontendEnvCaptor.getValue().get("NO_PROXY"));
    }

    @Test
    public void startRenamesGeneratedJavaFileToMatchPublicApplicationClass() throws Exception
    {
        when(frontProjectService.getProject(10L, 20L)).thenReturn(project());
        when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithMismatchedApplicationFile());
        when(portAllocator.allocate()).thenReturn(18080, 18081);
        when(databaseInitializer.initialize(any(File.class), anyString(), anyString(), anyInt(), anyString(), anyString()))
                .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test");
        whenBackendStarts();
        when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess);

        ProjectRunPreviewStatus status = runPreviewService.start(10L, 20L);

        File javaDirectory = new File(status.getWorkspacePath(), "demo-backend/src/main/java/com/example/demo");
        assertTrue(new File(javaDirectory, "DemoApplication.java").isFile());
        assertTrue(!new File(javaDirectory, "Application.java").exists());
    }

    @Test
    public void startSupportsCombinedDownloadPackageLayout() throws Exception
    {
        when(frontProjectService.getProject(10L, 20L)).thenReturn(project());
        when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithCombinedGeneratedProject());
        when(portAllocator.allocate()).thenReturn(18080, 18081, 18082);
        when(databaseInitializer.initialize(any(File.class), anyString(), eq("127.0.0.1"), eq(3306), eq("root"), eq("123456")))
                .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test");
        whenBackendStarts();
        when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess, adminFrontendProcess);

        ProjectRunPreviewStatus status = runPreviewService.start(10L, 20L);

        assertEquals("STARTING", status.getStatus());
        assertEquals("http://127.0.0.1:18081", status.getFrontendUrl());
        assertEquals("http://127.0.0.1:18082", status.getAdminFrontendUrl());
        assertTrue(new File(status.getWorkspacePath(), "demo-backend/pom.xml").isFile());
        assertTrue(new File(status.getWorkspacePath(), "demo-web/package.json").isFile());

        ArgumentCaptor<File> previewBuildDirectoryCaptor = ArgumentCaptor.forClass(File.class);
        ArgumentCaptor<File> rootPomCaptor = ArgumentCaptor.forClass(File.class);
        ArgumentCaptor<File> reactorPomCaptor = ArgumentCaptor.forClass(File.class);
        ArgumentCaptor<String> backendModulePathCaptor = ArgumentCaptor.forClass(String.class);
        ArgumentCaptor<File> localRepositoryCaptor = ArgumentCaptor.forClass(File.class);
        verify(processRunner).startBackend(previewBuildDirectoryCaptor.capture(), rootPomCaptor.capture(), reactorPomCaptor.capture(), backendModulePathCaptor.capture(), localRepositoryCaptor.capture(), any(Map.class));
        assertEquals(".easycode-preview", previewBuildDirectoryCaptor.getValue().getName());
        assertEquals("pom.xml", rootPomCaptor.getValue().getName());
        assertEquals("pom.xml", reactorPomCaptor.getValue().getName());
        assertEquals("../demo-backend", backendModulePathCaptor.getValue());
        assertEquals("repository", localRepositoryCaptor.getValue().getName());

        String reactorPom = new String(Files.readAllBytes(reactorPomCaptor.getValue().toPath()), StandardCharsets.UTF_8);
        assertTrue(reactorPom.contains("ruoyi-common"));
        assertTrue(reactorPom.contains("ruoyi-system"));
        assertTrue(reactorPom.contains("ruoyi-framework"));
        assertTrue(reactorPom.contains("<module>../demo-backend</module>"));

        ArgumentCaptor<File> frontendDirectoryCaptor = ArgumentCaptor.forClass(File.class);
        ArgumentCaptor<Map> frontendEnvCaptor = ArgumentCaptor.forClass(Map.class);
        verify(processRunner, times(2)).startFrontend(frontendDirectoryCaptor.capture(), frontendEnvCaptor.capture());
        assertEquals("demo-web", frontendDirectoryCaptor.getAllValues().get(0).getName());
        assertEquals("demo-admin", frontendDirectoryCaptor.getAllValues().get(1).getName());
        String frontendCache = (String) frontendEnvCaptor.getAllValues().get(0).get("NPM_CONFIG_CACHE");
        String adminFrontendCache = (String) frontendEnvCaptor.getAllValues().get(1).get("NPM_CONFIG_CACHE");
        assertEquals(frontendCache, adminFrontendCache);
        assertEquals(".npm-cache", new File(frontendCache).getName());
    }

    @Test
    public void startInitializesDatabaseFromBackendSqlWhenCombinedPackageHasRootSql() throws Exception
    {
        when(frontProjectService.getProject(10L, 20L)).thenReturn(project());
        when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithCombinedGeneratedProjectAndBackendSql());
        when(portAllocator.allocate()).thenReturn(18080, 18081);
        when(databaseInitializer.initialize(any(File.class), anyString(), eq("127.0.0.1"), eq(3306), eq("root"), eq("123456")))
                .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test");
        whenBackendStarts();
        when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess);

        ProjectRunPreviewStatus status = runPreviewService.start(10L, 20L);

        ArgumentCaptor<File> sqlDirectoryCaptor = ArgumentCaptor.forClass(File.class);
        verify(databaseInitializer).initialize(sqlDirectoryCaptor.capture(), anyString(), eq("127.0.0.1"), eq(3306), eq("root"), eq("123456"));
        assertEquals("STARTING", status.getStatus());
        assertEquals("sql", sqlDirectoryCaptor.getValue().getName());
        assertEquals("demo-backend", sqlDirectoryCaptor.getValue().getParentFile().getName());
    }

    @Test
    public void startWrapsGeneratedVueConfigForPreviewPorts() throws Exception
    {
        when(frontProjectService.getProject(10L, 20L)).thenReturn(project());
        when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithCombinedGeneratedProject());
        when(portAllocator.allocate()).thenReturn(18080, 18081);
        when(databaseInitializer.initialize(any(File.class), anyString(), eq("127.0.0.1"), eq(3306), eq("root"), eq("123456")))
                .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test");
        whenBackendStarts();
        when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess);

        ProjectRunPreviewStatus status = runPreviewService.start(10L, 20L);

        File frontendDirectory = new File(status.getWorkspacePath(), "demo-web");
        assertTrue(new File(frontendDirectory, "vue.config.generated.js").isFile());
        String previewConfig = new String(Files.readAllBytes(new File(frontendDirectory, "vue.config.js").toPath()), StandardCharsets.UTF_8);
        assertTrue(previewConfig.contains("process.env.PORT"));
        assertTrue(previewConfig.contains("process.env.VUE_APP_API_BASE_URL"));
        assertTrue(previewConfig.contains("vue.config.generated.js"));
    }

    @Test
    public void startWritesViteConfigForGeneratedViteFrontend() throws Exception
    {
        when(frontProjectService.getProject(10L, 20L)).thenReturn(project());
        when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithViteGeneratedProject());
        when(portAllocator.allocate()).thenReturn(18080, 18081);
        when(databaseInitializer.initialize(any(File.class), anyString(), eq("127.0.0.1"), eq(3306), eq("root"), eq("123456")))
                .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test");
        whenBackendStarts();
        when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess);

        ProjectRunPreviewStatus status = runPreviewService.start(10L, 20L);

        File frontendDirectory = new File(status.getWorkspacePath(), "demo-portal");
        String viteConfig = new String(Files.readAllBytes(new File(frontendDirectory, "vite.config.js").toPath()), StandardCharsets.UTF_8);
        assertTrue(viteConfig.contains("process.env.PORT"));
        assertTrue(viteConfig.contains("process.env.VUE_APP_API_BASE_URL"));
        assertTrue(viteConfig.contains("process.env.VITE_APP_API_BASE_URL"));

        ArgumentCaptor<Map> frontendEnvCaptor = ArgumentCaptor.forClass(Map.class);
        verify(processRunner).startFrontend(any(File.class), frontendEnvCaptor.capture());
        assertEquals("/api", frontendEnvCaptor.getValue().get("VITE_APP_BASE_API"));
    }

    @Test
    public void startUsesFreshWorkspaceWhenPreviousProjectWorkspaceExists() throws Exception
    {
        File legacyWorkspace = new File(temporaryFolder.getRoot(), "preview-workspaces/project-20");
        assertTrue(legacyWorkspace.mkdirs());
        File legacyFile = new File(legacyWorkspace, "server_code");
        assertTrue(legacyFile.createNewFile());

        when(frontProjectService.getProject(10L, 20L)).thenReturn(project());
        when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithGeneratedProject());
        when(portAllocator.allocate()).thenReturn(18080, 18081);
        when(databaseInitializer.initialize(any(File.class), anyString(), anyString(), anyInt(), anyString(), anyString()))
                .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test");
        whenBackendStarts();
        when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess);

        ProjectRunPreviewStatus status = runPreviewService.start(10L, 20L);

        assertEquals("STARTING", status.getStatus());
        assertTrue("legacy workspace should be left alone when a new preview starts", legacyFile.isFile());
        assertTrue("fresh workspace should include the project id and a unique suffix",
                new File(status.getWorkspacePath()).getName().startsWith("project-20-"));
    }

    @Test
    public void statusMarksPreviewRunningAfterFrontendServerIsReady() throws Exception
    {
        HttpServer frontendServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        frontendServer.createContext("/", new HttpHandler()
        {
            @Override
            public void handle(HttpExchange exchange) throws IOException
            {
                byte[] body = "ready".getBytes("UTF-8");
                exchange.sendResponseHeaders(200, body.length);
                OutputStream response = exchange.getResponseBody();
                response.write(body);
                response.close();
            }
        });
        frontendServer.start();

        try
        {
            when(frontProjectService.getProject(10L, 20L)).thenReturn(project());
            when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithGeneratedProject());
            when(portAllocator.allocate()).thenReturn(18080, frontendServer.getAddress().getPort());
            when(databaseInitializer.initialize(any(File.class), anyString(), anyString(), anyInt(), anyString(), anyString()))
                    .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test");
            whenBackendStarts();
            when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess);

            ProjectRunPreviewStatus startStatus = runPreviewService.start(10L, 20L);
            ProjectRunPreviewStatus readyStatus = runPreviewService.status(10L, 20L);

            assertEquals("STARTING", startStatus.getStatus());
            assertEquals("RUNNING", readyStatus.getStatus());
        }
        finally
        {
            frontendServer.stop(0);
        }
    }

    @Test
    public void statusWaitsForBothFrontendsWhenAdminFrontendExists() throws Exception
    {
        HttpServer frontendServer = readyServer();
        HttpServer adminFrontendServer = readyServer();
        frontendServer.start();

        try
        {
            when(frontProjectService.getProject(10L, 20L)).thenReturn(project());
            when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithCombinedGeneratedProject());
            when(portAllocator.allocate()).thenReturn(18080, frontendServer.getAddress().getPort(), adminFrontendServer.getAddress().getPort());
            when(databaseInitializer.initialize(any(File.class), anyString(), anyString(), anyInt(), anyString(), anyString()))
                    .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test");
            whenBackendStarts();
            when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess, adminFrontendProcess);

            runPreviewService.start(10L, 20L);
            ProjectRunPreviewStatus waitingStatus = runPreviewService.status(10L, 20L);
            adminFrontendServer.start();
            ProjectRunPreviewStatus readyStatus = runPreviewService.status(10L, 20L);

            assertEquals("STARTING", waitingStatus.getStatus());
            assertEquals("RUNNING", readyStatus.getStatus());
        }
        finally
        {
            frontendServer.stop(0);
            adminFrontendServer.stop(0);
        }
    }

    @Test
    public void statusUsesChineseMessageWhenGeneratedFrontendProcessExits() throws Exception
    {
        when(frontProjectService.getProject(10L, 20L)).thenReturn(project());
        when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithGeneratedProject());
        when(portAllocator.allocate()).thenReturn(18080, 18081);
        when(databaseInitializer.initialize(any(File.class), anyString(), anyString(), anyInt(), anyString(), anyString()))
                .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test");
        whenBackendStarts();
        when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess);

        runPreviewService.start(10L, 20L);
        frontendProcess.destroy();
        ProjectRunPreviewStatus status = runPreviewService.status(10L, 20L);

        assertEquals("FAILED", status.getStatus());
        assertEquals("生成的前台前端进程已退出", status.getMessage());
    }

    @Test
    public void stopDestroysRunningPreviewProcesses() throws Exception
    {
        when(frontProjectService.getProject(10L, 20L)).thenReturn(project());
        when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithGeneratedProject());
        when(portAllocator.allocate()).thenReturn(18080, 18081);
        when(databaseInitializer.initialize(any(File.class), anyString(), anyString(), anyInt(), anyString(), anyString()))
                .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test");
        whenBackendStarts();
        when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess);

        runPreviewService.start(10L, 20L);
        ProjectRunPreviewStatus status = runPreviewService.stop(10L, 20L);

        assertEquals("STOPPED", status.getStatus());
        assertTrue(backendProcess.destroyed);
        assertTrue(frontendProcess.destroyed);
        assertEquals(Integer.valueOf(18081), processKiller.stoppedPorts.get(0));
        assertEquals(Integer.valueOf(18080), processKiller.stoppedPorts.get(1));
    }

    @Test
    public void stopDestroysAdminFrontendAndAllListeningPorts() throws Exception
    {
        when(frontProjectService.getProject(10L, 20L)).thenReturn(project());
        when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithCombinedGeneratedProject());
        when(portAllocator.allocate()).thenReturn(18080, 18081, 18082);
        when(databaseInitializer.initialize(any(File.class), anyString(), anyString(), anyInt(), anyString(), anyString()))
                .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test");
        whenBackendStarts();
        when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess, adminFrontendProcess);

        runPreviewService.start(10L, 20L);
        ProjectRunPreviewStatus status = runPreviewService.stop(10L, 20L);

        assertEquals("STOPPED", status.getStatus());
        assertTrue(backendProcess.destroyed);
        assertTrue(frontendProcess.destroyed);
        assertTrue(adminFrontendProcess.destroyed);
        assertEquals(Integer.valueOf(18082), processKiller.stoppedPorts.get(0));
        assertEquals(Integer.valueOf(18081), processKiller.stoppedPorts.get(1));
        assertEquals(Integer.valueOf(18080), processKiller.stoppedPorts.get(2));
    }

    @Test
    public void statusReturnsNotStartedWhenNoSessionExists()
    {
        when(frontProjectService.getProject(10L, 20L)).thenReturn(project());

        ProjectRunPreviewStatus status = runPreviewService.status(10L, 20L);

        assertEquals("NOT_STARTED", status.getStatus());
        assertEquals(Long.valueOf(20L), status.getProjectId());
    }

    private FrontProject project()
    {
        FrontProject project = new FrontProject();
        project.setProjectId(20L);
        project.setProjectName("Demo");
        project.setProjectFileName("demo");
        return project;
    }

    private void whenBackendStarts() throws IOException
    {
        when(processRunner.startBackend(any(File.class), any(File.class), any(File.class), anyString(), any(File.class), any(Map.class)))
                .thenReturn(backendProcess);
    }

    private HttpServer readyServer() throws IOException
    {
        HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        server.createContext("/", new HttpHandler()
        {
            @Override
            public void handle(HttpExchange exchange) throws IOException
            {
                byte[] body = "ready".getBytes("UTF-8");
                exchange.sendResponseHeaders(200, body.length);
                OutputStream response = exchange.getResponseBody();
                response.write(body);
                response.close();
            }
        });
        return server;
    }

    private byte[] zipWithGeneratedProject() throws IOException
    {
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        ZipOutputStream zip = new ZipOutputStream(outputStream);
        addEntry(zip, "pom.xml", "<project />");
        addEntry(zip, "vue/package.json", "{\"scripts\":{\"dev\":\"vue-cli-service serve\"}}");
        addEntry(zip, "sql/demo.sql", "create table demo (id bigint);");
        zip.close();
        return outputStream.toByteArray();
    }

    private byte[] zipWithMismatchedApplicationFile() throws IOException
    {
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        ZipOutputStream zip = new ZipOutputStream(outputStream);
        addEntry(zip, "demo-backend/pom.xml", "<project />");
        addEntry(zip, "demo-backend/src/main/java/com/example/demo/Application.java",
                "package com.example.demo;\n\npublic class DemoApplication {}\n");
        addEntry(zip, "demo-web/package.json", "{\"scripts\":{\"dev\":\"vue-cli-service serve\"}}");
        addEntry(zip, "sql/demo.sql", "create table demo (id bigint);");
        zip.close();
        return outputStream.toByteArray();
    }

    private byte[] zipWithCombinedGeneratedProject() throws IOException
    {
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        ZipOutputStream zip = new ZipOutputStream(outputStream);
        addEntry(zip, "demo-backend/pom.xml", "<project />");
        addEntry(zip, "demo-web/package.json", "{\"scripts\":{\"dev\":\"vue-cli-service serve\"}}");
        addEntry(zip, "demo-web/vue.config.js", "module.exports = { devServer: { port: 8082, proxy: { \"/api\": { target: \"http://localhost:8080\" } } } }");
        addEntry(zip, "demo-admin/package.json", "{\"scripts\":{\"dev\":\"vue-cli-service serve\"}}");
        addEntry(zip, "sql/demo.sql", "create table demo (id bigint);");
        zip.close();
        return outputStream.toByteArray();
    }

    private byte[] zipWithCombinedGeneratedProjectAndBackendSql() throws IOException
    {
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        ZipOutputStream zip = new ZipOutputStream(outputStream);
        addEntry(zip, "demo-backend/pom.xml", "<project />");
        addEntry(zip, "demo-backend/sql/demo.sql", "create table demo_backend (id bigint);");
        addEntry(zip, "demo-web/package.json", "{\"scripts\":{\"dev\":\"vue-cli-service serve\"}}");
        addEntry(zip, "sql/demo.sql", "create table demo_root (id bigint);");
        zip.close();
        return outputStream.toByteArray();
    }

    private byte[] zipWithViteGeneratedProject() throws IOException
    {
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        ZipOutputStream zip = new ZipOutputStream(outputStream);
        addEntry(zip, "demo-backend/pom.xml", "<project />");
        addEntry(zip, "demo-portal/package.json", "{\"scripts\":{\"dev\":\"vite --host 0.0.0.0\"},\"dependencies\":{\"@vitejs/plugin-vue\":\"5.0.5\",\"vite\":\"5.3.1\",\"vue\":\"3.4.29\"}}");
        addEntry(zip, "sql/demo.sql", "create table demo (id bigint);");
        zip.close();
        return outputStream.toByteArray();
    }

    private void addEntry(ZipOutputStream zip, String name, String content) throws IOException
    {
        zip.putNextEntry(new ZipEntry(name));
        zip.write(content.getBytes("UTF-8"));
        zip.closeEntry();
    }

    private void setField(String name, Object value) throws Exception
    {
        Field field = FrontProjectRunPreviewServiceImpl.class.getDeclaredField(name);
        field.setAccessible(true);
        field.set(runPreviewService, value);
    }

    private static class FakeProcess extends Process
    {
        private boolean alive = true;
        private boolean destroyed = false;

        @Override
        public OutputStream getOutputStream()
        {
            return new ByteArrayOutputStream();
        }

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

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

        @Override
        public int waitFor()
        {
            alive = false;
            return 0;
        }

        @Override
        public int exitValue()
        {
            if (alive)
            {
                throw new IllegalThreadStateException();
            }
            return 0;
        }

        @Override
        public void destroy()
        {
            alive = false;
            destroyed = true;
        }

        @Override
        public boolean isAlive()
        {
            return alive;
        }
    }

    private static class RecordingProcessKiller extends RunPreviewProcessKiller
    {
        private final List<Integer> stoppedPorts = new ArrayList<Integer>();

        @Override
        public void stop(Process process, Integer port)
        {
            if (process != null)
            {
                process.destroy();
            }
            if (port != null)
            {
                stoppedPorts.add(port);
            }
        }
    }
}
