package com.ruoyi.generator.service.front;

import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.generator.domain.front.dto.ProjectRunPreviewStatus;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.io.ByteArrayInputStream;

@Service
public class FrontProjectRunPreviewServiceImpl implements IFrontProjectRunPreviewService
{
    private static final int MAX_LOG_LENGTH = 12000;
    private static final Pattern PUBLIC_CLASS_PATTERN = Pattern.compile("\\bpublic\\s+(?:abstract\\s+|final\\s+)?class\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\b");

    @Autowired
    private IFrontProjectService frontProjectService;
    @Autowired
    private IFrontProjectPreviewService frontProjectPreviewService;
    @Autowired
    private RunPreviewPortAllocator portAllocator;
    @Autowired
    private RunPreviewDatabaseInitializer databaseInitializer;
    @Autowired
    private RunPreviewProcessRunner processRunner;
    @Autowired
    private RunPreviewProcessKiller processKiller;

    @Value("${easycode.preview.workspace-root:preview-workspaces}")
    private String workspaceRoot;
    @Value("${easycode.preview.mysql-host:127.0.0.1}")
    private String mysqlHost;
    @Value("${easycode.preview.mysql-port:3306}")
    private int mysqlPort;
    @Value("${easycode.preview.mysql-username:root}")
    private String mysqlUsername;
    @Value("${easycode.preview.mysql-password:123456}")
    private String mysqlPassword;

    private final Map<String, RunPreviewSession> sessions = new ConcurrentHashMap<String, RunPreviewSession>();

    @Override
    public ProjectRunPreviewStatus start(Long userId, Long projectId)
    {
        frontProjectService.getProject(userId, projectId);
        String key = sessionKey(userId, projectId);
        RunPreviewSession existing = sessions.get(key);
        if (existing != null)
        {
            destroyProcesses(existing);
        }

        RunPreviewSession session = new RunPreviewSession();
        session.projectId = projectId;
        session.status = ProjectRunPreviewStatus.PREPARING;
        session.message = "Preparing preview workspace";
        session.startedAt = now();
        session.updatedAt = session.startedAt;
        sessions.put(key, session);

        try
        {
            byte[] sourceZip = frontProjectPreviewService.downloadAll(userId, projectId);
            File workspace = prepareWorkspace(projectId, sourceZip);
            session.workspacePath = workspace.getAbsolutePath();

            int backendPort = portAllocator.allocate();
            int frontendPort = portAllocator.allocate();
            String databaseName = "preview_" + projectId + "_" + System.currentTimeMillis();
            String backendUrl = "http://127.0.0.1:" + backendPort;
            String frontendUrl = "http://127.0.0.1:" + frontendPort;
            File backendDirectory = resolveBackendDirectory(workspace);
            String databaseUrl = databaseInitializer.initialize(resolveSqlDirectory(workspace, backendDirectory),
                    databaseName, mysqlHost, mysqlPort, mysqlUsername, mysqlPassword);
            applyBackendPreviewCompatibilityFixes(backendDirectory);
            File frontendDirectory = resolveFrontendDirectory(workspace);
            File adminFrontendDirectory = resolveAdminFrontendDirectory(workspace, frontendDirectory);
            File repositoryRoot = resolveRepositoryRoot();
            File previewBuildDirectory = preparePreviewBuildDirectory(workspace);
            File backendReactorPom = writeBackendReactorPom(previewBuildDirectory, repositoryRoot, backendDirectory);
            String backendModulePath = mavenPath(previewBuildDirectory, backendDirectory);
            File localRepository = new File(workspace.getParentFile(), ".m2/repository").getCanonicalFile();
            File sharedNpmCache = new File(workspace.getParentFile(), ".npm-cache").getCanonicalFile();
            Integer adminFrontendPort = adminFrontendDirectory == null ? null : portAllocator.allocate();
            String adminFrontendUrl = adminFrontendPort == null ? null : "http://127.0.0.1:" + adminFrontendPort;
            applyFrontendPreviewConfig(frontendDirectory);
            if (adminFrontendDirectory != null)
            {
                applyFrontendPreviewConfig(adminFrontendDirectory);
            }

            session.backendPort = backendPort;
            session.frontendPort = frontendPort;
            session.adminFrontendPort = adminFrontendPort;
            session.backendUrl = backendUrl;
            session.frontendUrl = frontendUrl;
            session.adminFrontendUrl = adminFrontendUrl;
            session.databaseName = databaseName;
            session.status = ProjectRunPreviewStatus.STARTING;
            session.message = "正在启动生成的后端和前端服务";
            session.updatedAt = now();

            Map<String, String> backendEnv = new HashMap<String, String>();
            backendEnv.put("SERVER_PORT", String.valueOf(backendPort));
            backendEnv.put("DB_URL", databaseUrl);
            backendEnv.put("DB_USERNAME", mysqlUsername);
            backendEnv.put("DB_PASSWORD", mysqlPassword);
            putJavaPreviewEnvironment(backendEnv, new File(previewBuildDirectory, "java-tmp/backend"));

            Map<String, String> frontendEnv = new HashMap<String, String>();
            frontendEnv.put("PORT", String.valueOf(frontendPort));
            frontendEnv.put("VUE_APP_API_BASE_URL", backendUrl);
            frontendEnv.put("VITE_APP_API_BASE_URL", backendUrl);
            frontendEnv.put("VITE_APP_BASE_API", "/api");
            putNpmPreviewEnvironment(frontendEnv, sharedNpmCache);

            Map<String, String> adminFrontendEnv = null;
            if (adminFrontendPort != null)
            {
                adminFrontendEnv = new HashMap<String, String>();
                adminFrontendEnv.put("PORT", String.valueOf(adminFrontendPort));
                adminFrontendEnv.put("VUE_APP_API_BASE_URL", backendUrl);
                adminFrontendEnv.put("VITE_APP_API_BASE_URL", backendUrl);
                adminFrontendEnv.put("VITE_APP_BASE_API", "/api");
                putNpmPreviewEnvironment(adminFrontendEnv, sharedNpmCache);
            }

            session.backendProcess = processRunner.startBackend(previewBuildDirectory, new File(repositoryRoot, "pom.xml"),
                    backendReactorPom, backendModulePath, localRepository, backendEnv);
            session.frontendProcess = processRunner.startFrontend(frontendDirectory, frontendEnv);
            if (adminFrontendDirectory != null)
            {
                session.adminFrontendProcess = processRunner.startFrontend(adminFrontendDirectory, adminFrontendEnv);
            }
            startLogReader(session, session.backendProcess, "backend");
            startLogReader(session, session.frontendProcess, "frontend");
            startLogReader(session, session.adminFrontendProcess, "adminFrontend");

            session.status = ProjectRunPreviewStatus.STARTING;
            session.message = "预览进程已启动，正在等待前端服务就绪";
            session.updatedAt = now();
        }
        catch (Exception e)
        {
            session.status = ProjectRunPreviewStatus.FAILED;
            session.message = e.getMessage();
            session.updatedAt = now();
            appendLog(session, "preview failed: " + e.getMessage());
            destroyProcesses(session);
        }
        return toStatus(session);
    }

    private File resolveBackendDirectory(File workspace)
    {
        if (new File(workspace, "pom.xml").isFile())
        {
            return workspace;
        }
        List<File> candidates = findDirectoriesContaining(workspace, "pom.xml");
        File preferred = pickPreferredDirectory(candidates, "-backend", "backend");
        if (preferred == null)
        {
            throw new ServiceException("Generated project does not contain a runnable backend directory with pom.xml");
        }
        return preferred;
    }

    private File resolveFrontendDirectory(File workspace)
    {
        File legacyVueDirectory = new File(workspace, "vue");
        if (new File(legacyVueDirectory, "package.json").isFile())
        {
            return legacyVueDirectory;
        }
        List<File> candidates = findDirectoriesContaining(workspace, "package.json");
        File preferred = pickPreferredDirectory(candidates, "client", "-web", "frontend", "portal", "vue");
        if (preferred == null)
        {
            throw new ServiceException("Generated project does not contain a runnable frontend directory with package.json");
        }
        return preferred;
    }

    private File resolveAdminFrontendDirectory(File workspace, File frontendDirectory) throws IOException
    {
        List<File> candidates = findDirectoriesContaining(workspace, "package.json");
        List<File> adminCandidates = new ArrayList<File>();
        String frontendPath = frontendDirectory == null ? null : frontendDirectory.getCanonicalPath();
        for (File candidate : candidates)
        {
            if (frontendPath != null && frontendPath.equals(candidate.getCanonicalPath()))
            {
                continue;
            }
            adminCandidates.add(candidate);
        }
        return pickPreferredDirectory(adminCandidates, "manage", "-admin", "admin");
    }

    private void applyFrontendPreviewConfig(File frontendDirectory) throws IOException
    {
        if (isViteFrontend(frontendDirectory))
        {
            applyVitePreviewConfig(frontendDirectory);
            return;
        }

        applyVueCliPreviewConfig(frontendDirectory);
    }

    private void applyVueCliPreviewConfig(File frontendDirectory) throws IOException
    {
        File vueConfig = new File(frontendDirectory, "vue.config.js");
        File generatedConfig = new File(frontendDirectory, "vue.config.generated.js");
        boolean hasGeneratedConfig = vueConfig.isFile();
        if (hasGeneratedConfig)
        {
            copyGeneratedPreviewConfig(vueConfig, generatedConfig, "vue");
        }

        String requireGeneratedConfig = hasGeneratedConfig
                ? "const generated = require(\"./vue.config.generated.js\")\n"
                : "const generated = {}\n";
        String previewConfig = requireGeneratedConfig
                + "const generatedDevServer = generated.devServer || {}\n"
                + "const generatedProxy = generatedDevServer.proxy || {}\n"
                + "const generatedApiProxy = generatedProxy[\"/api\"] || {}\n"
                + "const port = process.env.PORT || generatedDevServer.port || 8081\n"
                + "const apiBaseUrl = process.env.VUE_APP_API_BASE_URL || generatedApiProxy.target || \"http://localhost:8080\"\n"
                + "\n"
                + "module.exports = Object.assign({}, generated, {\n"
                + "  devServer: Object.assign({}, generatedDevServer, {\n"
                + "    port: port,\n"
                + "    proxy: Object.assign({}, generatedProxy, {\n"
                + "      \"/api\": Object.assign({}, generatedApiProxy, {\n"
                + "        target: apiBaseUrl,\n"
                + "        changeOrigin: true,\n"
                + "        pathRewrite: Object.assign({}, generatedApiProxy.pathRewrite || {}, {\n"
                + "          \"^/api\": \"\"\n"
                + "        })\n"
                + "      })\n"
                + "    })\n"
                + "  })\n"
                + "})\n";
        writeUtf8(vueConfig, previewConfig);
    }

    private void applyVitePreviewConfig(File frontendDirectory) throws IOException
    {
        File viteConfig = new File(frontendDirectory, "vite.config.js");
        File generatedConfig = new File(frontendDirectory, "vite.config.generated.js");
        boolean hasGeneratedConfig = viteConfig.isFile();
        if (hasGeneratedConfig)
        {
            copyGeneratedPreviewConfig(viteConfig, generatedConfig, "vite");
        }

        String generatedConfigImport = hasGeneratedConfig
                ? "import generated from \"./vite.config.generated.js\"\n"
                : "const generated = {}\n";
        String previewConfig = "import { defineConfig, mergeConfig } from \"vite\"\n"
                + "import vue from \"@vitejs/plugin-vue\"\n"
                + generatedConfigImport
                + "\n"
                + "const port = Number(process.env.PORT || 8081)\n"
                + "const apiBaseUrl = process.env.VITE_APP_API_BASE_URL || process.env.VUE_APP_API_BASE_URL || \"http://localhost:8080\"\n"
                + "const previewConfig = {\n"
                + "  plugins: [vue()],\n"
                + "  server: {\n"
                + "    host: \"0.0.0.0\",\n"
                + "    port: port,\n"
                + "    proxy: {\n"
                + "      \"/api\": {\n"
                + "        target: apiBaseUrl,\n"
                + "        changeOrigin: true,\n"
                + "        rewrite: path => path.replace(/^\\/api/, \"\")\n"
                + "      }\n"
                + "    }\n"
                + "  }\n"
                + "}\n"
                + "\n"
                + "export default defineConfig(configEnv => mergeConfig(\n"
                + "  typeof generated === \"function\" ? generated(configEnv) : generated,\n"
                + "  previewConfig\n"
                + "))\n";
        writeUtf8(viteConfig, previewConfig);
    }

    private void putJavaPreviewEnvironment(Map<String, String> environment, File tempDirectory) throws IOException
    {
        putJavaHome(environment);
        if (!tempDirectory.mkdirs() && !tempDirectory.isDirectory())
        {
            throw new IOException("Unable to create preview Java temp directory");
        }
        String tempOption = "-Djava.io.tmpdir=" + tempDirectory.getAbsolutePath();
        putJvmOption(environment, "MAVEN_OPTS", tempOption);
        putJvmOption(environment, "JAVA_TOOL_OPTIONS", tempOption);
    }

    private void putJavaHome(Map<String, String> environment)
    {
        String javaHome = resolveJavaHome();
        if (hasText(javaHome))
        {
            environment.put("JAVA_HOME", javaHome);
        }
    }

    private String resolveJavaHome()
    {
        String configuredJavaHome = System.getenv("JAVA_HOME");
        if (hasText(configuredJavaHome))
        {
            return configuredJavaHome;
        }

        String javaHome = System.getProperty("java.home");
        if (!hasText(javaHome))
        {
            return null;
        }
        File javaHomeFile = new File(javaHome);
        if ("jre".equalsIgnoreCase(javaHomeFile.getName()) && javaHomeFile.getParentFile() != null)
        {
            return javaHomeFile.getParentFile().getAbsolutePath();
        }
        return javaHomeFile.getAbsolutePath();
    }

    private void putJvmOption(Map<String, String> environment, String key, String option)
    {
        String current = environment.get(key);
        if (!hasText(current))
        {
            current = System.getenv(key);
        }
        environment.put(key, hasText(current) ? current + " " + option : option);
    }

    private void putNpmPreviewEnvironment(Map<String, String> environment, File cacheDirectory) throws IOException
    {
        if (!cacheDirectory.mkdirs() && !cacheDirectory.isDirectory())
        {
            throw new IOException("Unable to create preview npm cache directory");
        }
        String cachePath = cacheDirectory.getAbsolutePath();
        environment.put("NPM_CONFIG_CACHE", cachePath);
        environment.put("npm_config_cache", cachePath);
        environment.put("NPM_CONFIG_UPDATE_NOTIFIER", "false");
        environment.put("NO_UPDATE_NOTIFIER", "1");
        environment.put("NPM_CONFIG_AUDIT", "false");
        environment.put("NPM_CONFIG_FUND", "false");
        environment.put("NPM_CONFIG_OFFLINE", "false");
        environment.put("npm_config_offline", "false");
        environment.put("NPM_CONFIG_PREFER_OFFLINE", "true");
        environment.put("npm_config_prefer_offline", "true");
        environment.put("NPM_CONFIG_PREFER_ONLINE", "false");
        environment.put("npm_config_prefer_online", "false");
        environment.put("NPM_CONFIG_CACHE_MIN", "0");
        environment.put("npm_config_cache_min", "0");
        environment.put("NPM_CONFIG_PROXY", "");
        environment.put("npm_config_proxy", "");
        environment.put("NPM_CONFIG_HTTPS_PROXY", "");
        environment.put("npm_config_https_proxy", "");
        environment.put("HTTP_PROXY", "");
        environment.put("http_proxy", "");
        environment.put("HTTPS_PROXY", "");
        environment.put("https_proxy", "");
        environment.put("ALL_PROXY", "");
        environment.put("all_proxy", "");
        environment.put("NO_PROXY", "*");
        environment.put("no_proxy", "*");
    }

    private boolean hasText(String value)
    {
        return value != null && value.trim().length() > 0;
    }

    private void copyGeneratedPreviewConfig(File sourceConfig, File generatedConfig, String configType) throws IOException
    {
        try
        {
            Files.copy(sourceConfig.toPath(), generatedConfig.toPath(), StandardCopyOption.REPLACE_EXISTING);
        }
        catch (IOException e)
        {
            throw new IOException("Unable to prepare generated " + configType + " preview config", e);
        }
    }

    private boolean isViteFrontend(File frontendDirectory) throws IOException
    {
        File packageJsonFile = new File(frontendDirectory, "package.json");
        if (!packageJsonFile.isFile())
        {
            return false;
        }

        String content = new String(Files.readAllBytes(packageJsonFile.toPath()), Charset.forName("UTF-8"));
        JSONObject packageJson = JSON.parseObject(content);
        return hasDependency(packageJson.getJSONObject("dependencies"), "vite")
                || hasDependency(packageJson.getJSONObject("devDependencies"), "vite")
                || hasViteScript(packageJson.getJSONObject("scripts"));
    }

    private boolean hasDependency(JSONObject dependencies, String dependencyName)
    {
        return dependencies != null && dependencies.containsKey(dependencyName);
    }

    private boolean hasViteScript(JSONObject scripts)
    {
        if (scripts == null)
        {
            return false;
        }
        for (String key : scripts.keySet())
        {
            String command = scripts.getString(key);
            if (command != null && command.contains("vite"))
            {
                return true;
            }
        }
        return false;
    }

    private void writeUtf8(File file, String content) throws IOException
    {
        FileOutputStream output = new FileOutputStream(file);
        try
        {
            output.write(content.getBytes(Charset.forName("UTF-8")));
        }
        finally
        {
            output.close();
        }
    }

    private File resolveRepositoryRoot() throws IOException
    {
        File current = new File("").getCanonicalFile();
        while (current != null)
        {
            if (new File(current, "pom.xml").isFile()
                    && new File(current, "ruoyi-common").isDirectory()
                    && new File(current, "ruoyi-framework").isDirectory())
            {
                return current;
            }
            current = current.getParentFile();
        }
        throw new ServiceException("Unable to locate RuoYi repository root for preview backend");
    }

    private File preparePreviewBuildDirectory(File workspace) throws IOException
    {
        File previewBuildDirectory = new File(workspace, ".easycode-preview").getCanonicalFile();
        ensureChildPath(workspace, previewBuildDirectory);
        if (!previewBuildDirectory.mkdirs() && !previewBuildDirectory.isDirectory())
        {
            throw new IOException("Unable to create preview build directory");
        }
        return previewBuildDirectory;
    }

    private File writeBackendReactorPom(File previewBuildDirectory, File repositoryRoot, File backendDirectory) throws IOException
    {
        File reactorPom = new File(previewBuildDirectory, "pom.xml").getCanonicalFile();
        String content = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
                + "<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n"
                + "         xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n"
                + "         xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd\">\n"
                + "  <modelVersion>4.0.0</modelVersion>\n"
                + "  <groupId>com.easycode.preview</groupId>\n"
                + "  <artifactId>easycode-preview-reactor</artifactId>\n"
                + "  <version>1.0.0</version>\n"
                + "  <packaging>pom</packaging>\n"
                + "  <modules>\n"
                + "    <module>" + xmlEscape(mavenPath(previewBuildDirectory, new File(repositoryRoot, "ruoyi-common"))) + "</module>\n"
                + "    <module>" + xmlEscape(mavenPath(previewBuildDirectory, new File(repositoryRoot, "ruoyi-system"))) + "</module>\n"
                + "    <module>" + xmlEscape(mavenPath(previewBuildDirectory, new File(repositoryRoot, "ruoyi-framework"))) + "</module>\n"
                + "    <module>" + xmlEscape(mavenPath(previewBuildDirectory, backendDirectory)) + "</module>\n"
                + "  </modules>\n"
                + "</project>\n";
        FileOutputStream output = new FileOutputStream(reactorPom);
        try
        {
            output.write(content.getBytes(Charset.forName("UTF-8")));
        }
        finally
        {
            output.close();
        }
        return reactorPom;
    }

    private String mavenPath(File fromDirectory, File target) throws IOException
    {
        File from = fromDirectory.getCanonicalFile();
        File to = target.getCanonicalFile();
        Path fromPath = from.toPath();
        Path toPath = to.toPath();
        Path fromRoot = fromPath.getRoot();
        Path toRoot = toPath.getRoot();
        String path;
        if (fromRoot != null && fromRoot.equals(toRoot))
        {
            path = fromPath.relativize(toPath).toString();
        }
        else
        {
            path = to.getAbsolutePath();
        }
        return path.replace(File.separatorChar, '/');
    }

    private String xmlEscape(String value)
    {
        return value.replace("&", "&amp;")
                .replace("<", "&lt;")
                .replace(">", "&gt;")
                .replace("\"", "&quot;");
    }

    private List<File> findDirectoriesContaining(File root, String fileName)
    {
        List<File> candidates = new ArrayList<File>();
        collectDirectoriesContaining(root, fileName, candidates);
        Collections.sort(candidates, new Comparator<File>()
        {
            @Override
            public int compare(File left, File right)
            {
                return left.getAbsolutePath().compareTo(right.getAbsolutePath());
            }
        });
        return candidates;
    }

    private void collectDirectoriesContaining(File directory, String fileName, List<File> candidates)
    {
        if (directory == null || !directory.isDirectory())
        {
            return;
        }
        if ("node_modules".equals(directory.getName()))
        {
            return;
        }
        if (new File(directory, fileName).isFile())
        {
            candidates.add(directory);
        }
        File[] children = directory.listFiles();
        if (children == null)
        {
            return;
        }
        for (File child : children)
        {
            if (child.isDirectory())
            {
                collectDirectoriesContaining(child, fileName, candidates);
            }
        }
    }

    private File pickPreferredDirectory(List<File> candidates, String... nameMarkers)
    {
        if (candidates == null || candidates.isEmpty())
        {
            return null;
        }
        for (String marker : nameMarkers)
        {
            for (File candidate : candidates)
            {
                String name = candidate.getName().toLowerCase();
                if (name.contains(marker.toLowerCase()))
                {
                    return candidate;
                }
            }
        }
        return candidates.get(0);
    }

    private File resolveSqlDirectory(File workspace, File backendDirectory)
    {
        File backendSqlDirectory = new File(backendDirectory, "sql");
        if (containsSqlFile(backendSqlDirectory))
        {
            return backendSqlDirectory;
        }

        File rootSqlDirectory = new File(workspace, "sql");
        if (containsSqlFile(rootSqlDirectory))
        {
            return rootSqlDirectory;
        }

        return backendSqlDirectory.isDirectory() ? backendSqlDirectory : rootSqlDirectory;
    }

    private boolean containsSqlFile(File directory)
    {
        if (directory == null || !directory.isDirectory())
        {
            return false;
        }
        File[] files = directory.listFiles();
        if (files == null)
        {
            return false;
        }
        for (File file : files)
        {
            if (file.isFile() && file.getName().toLowerCase().endsWith(".sql"))
            {
                return true;
            }
        }
        return false;
    }

    private void applyBackendPreviewCompatibilityFixes(File backendDirectory) throws IOException
    {
        File sourceRoot = new File(backendDirectory, "src/main/java");
        if (!sourceRoot.isDirectory())
        {
            return;
        }
        List<File> javaFiles = new ArrayList<File>();
        collectJavaFiles(sourceRoot, javaFiles);
        for (File javaFile : javaFiles)
        {
            alignJavaFileNameWithPublicClass(backendDirectory, javaFile);
        }
    }

    private void collectJavaFiles(File directory, List<File> javaFiles)
    {
        File[] children = directory.listFiles();
        if (children == null)
        {
            return;
        }
        for (File child : children)
        {
            if (child.isDirectory())
            {
                collectJavaFiles(child, javaFiles);
            }
            else if (child.isFile() && child.getName().endsWith(".java"))
            {
                javaFiles.add(child);
            }
        }
    }

    private void alignJavaFileNameWithPublicClass(File backendDirectory, File javaFile) throws IOException
    {
        String content = new String(Files.readAllBytes(javaFile.toPath()), Charset.forName("UTF-8"));
        Matcher matcher = PUBLIC_CLASS_PATTERN.matcher(content);
        if (!matcher.find())
        {
            return;
        }
        String expectedFileName = matcher.group(1) + ".java";
        if (expectedFileName.equals(javaFile.getName()))
        {
            return;
        }
        File target = new File(javaFile.getParentFile(), expectedFileName).getCanonicalFile();
        ensureChildPath(backendDirectory, target);
        if (target.exists())
        {
            throw new IOException("Generated backend contains duplicate Java public class file: " + target.getAbsolutePath());
        }
        Files.move(javaFile.toPath(), target.toPath());
    }

    @Override
    public ProjectRunPreviewStatus status(Long userId, Long projectId)
    {
        frontProjectService.getProject(userId, projectId);
        RunPreviewSession session = sessions.get(sessionKey(userId, projectId));
        if (session == null)
        {
            return notStarted(projectId);
        }
        refreshProcessStatus(session);
        return toStatus(session);
    }

    @Override
    public ProjectRunPreviewStatus stop(Long userId, Long projectId)
    {
        frontProjectService.getProject(userId, projectId);
        RunPreviewSession session = sessions.get(sessionKey(userId, projectId));
        if (session == null)
        {
            return notStarted(projectId);
        }
        destroyProcesses(session);
        session.status = ProjectRunPreviewStatus.STOPPED;
        session.message = "Preview stopped";
        session.updatedAt = now();
        return toStatus(session);
    }

    private File prepareWorkspace(Long projectId, byte[] sourceZip) throws IOException
    {
        File root = new File(workspaceRoot).getCanonicalFile();
        if (!root.mkdirs() && !root.isDirectory())
        {
            throw new IOException("Unable to create preview workspace root");
        }
        File workspace = nextWorkspace(root, projectId);
        ensureChildPath(root, workspace);
        if (!workspace.mkdirs() && !workspace.isDirectory())
        {
            throw new IOException("Unable to create preview workspace");
        }
        unzip(sourceZip, workspace);
        return workspace;
    }

    private File nextWorkspace(File root, Long projectId) throws IOException
    {
        String baseName = "project-" + projectId + "-" + System.currentTimeMillis();
        File workspace = new File(root, baseName).getCanonicalFile();
        int index = 1;
        while (workspace.exists())
        {
            workspace = new File(root, baseName + "-" + index).getCanonicalFile();
            index++;
        }
        return workspace;
    }

    private void unzip(byte[] sourceZip, File workspace) throws IOException
    {
        ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(sourceZip));
        try
        {
            ZipEntry entry;
            byte[] buffer = new byte[4096];
            while ((entry = zip.getNextEntry()) != null)
            {
                String entryName = normalizeEntryName(entry.getName());
                if (entryName == null)
                {
                    zip.closeEntry();
                    continue;
                }
                File target = new File(workspace, entryName).getCanonicalFile();
                ensureChildPath(workspace, target);
                if (entry.isDirectory())
                {
                    if (!target.mkdirs() && !target.isDirectory())
                    {
                        throw new IOException("Unable to create directory " + target.getAbsolutePath());
                    }
                }
                else
                {
                    File parent = target.getParentFile();
                    if (parent != null && !parent.mkdirs() && !parent.isDirectory())
                    {
                        throw new IOException("Unable to create directory " + parent.getAbsolutePath());
                    }
                    FileOutputStream output = new FileOutputStream(target);
                    try
                    {
                        int length;
                        while ((length = zip.read(buffer)) > -1)
                        {
                            output.write(buffer, 0, length);
                        }
                    }
                    finally
                    {
                        output.close();
                    }
                }
                zip.closeEntry();
            }
        }
        finally
        {
            zip.close();
        }
    }

    private String normalizeEntryName(String entryName)
    {
        if (entryName == null)
        {
            return null;
        }
        String normalized = entryName.replace('\\', '/').trim();
        while (normalized.startsWith("/"))
        {
            normalized = normalized.substring(1);
        }
        if (normalized.length() == 0)
        {
            return null;
        }
        if (normalized.startsWith("../") || normalized.contains("/../"))
        {
            throw new ServiceException("Unsafe generated project path");
        }
        return normalized;
    }

    private void deleteRecursively(File file) throws IOException
    {
        if (file == null || !file.exists())
        {
            return;
        }
        if (file.isDirectory())
        {
            File[] children = file.listFiles();
            if (children != null)
            {
                for (File child : children)
                {
                    deleteRecursively(child);
                }
            }
        }
        if (!file.delete())
        {
            throw new IOException("Unable to clean preview workspace: " + file.getAbsolutePath());
        }
    }

    private void ensureChildPath(File root, File child) throws IOException
    {
        String rootPath = root.getCanonicalPath();
        String childPath = child.getCanonicalPath();
        if (!childPath.equals(rootPath) && !childPath.startsWith(rootPath + File.separator))
        {
            throw new ServiceException("Preview workspace path is outside the configured root");
        }
    }

    private void refreshProcessStatus(RunPreviewSession session)
    {
        if (ProjectRunPreviewStatus.STOPPED.equals(session.status) || ProjectRunPreviewStatus.FAILED.equals(session.status))
        {
            return;
        }
        if (session.backendProcess != null && !session.backendProcess.isAlive())
        {
            session.status = ProjectRunPreviewStatus.FAILED;
            session.message = "生成的后端进程已退出";
            session.updatedAt = now();
            return;
        }
        if (session.frontendProcess != null && !session.frontendProcess.isAlive())
        {
            session.status = ProjectRunPreviewStatus.FAILED;
            session.message = "生成的前台前端进程已退出";
            session.updatedAt = now();
            return;
        }
        if (session.adminFrontendProcess != null && !session.adminFrontendProcess.isAlive())
        {
            session.status = ProjectRunPreviewStatus.FAILED;
            session.message = "生成的后台前端进程已退出";
            session.updatedAt = now();
            return;
        }
        if (areFrontendUrlsReady(session))
        {
            if (!ProjectRunPreviewStatus.RUNNING.equals(session.status))
            {
                session.status = ProjectRunPreviewStatus.RUNNING;
                session.message = "运行预览已就绪";
                session.updatedAt = now();
            }
        }
        else if (!ProjectRunPreviewStatus.STARTING.equals(session.status))
        {
            session.status = ProjectRunPreviewStatus.STARTING;
            session.message = "正在等待前端预览服务就绪";
            session.updatedAt = now();
        }
    }

    private boolean areFrontendUrlsReady(RunPreviewSession session)
    {
        if (!isUrlReady(session.frontendUrl))
        {
            return false;
        }
        return session.adminFrontendUrl == null || isUrlReady(session.adminFrontendUrl);
    }

    private boolean isUrlReady(String url)
    {
        if (url == null || url.trim().length() == 0)
        {
            return false;
        }
        HttpURLConnection connection = null;
        try
        {
            connection = (HttpURLConnection) new URL(url).openConnection();
            connection.setConnectTimeout(1000);
            connection.setReadTimeout(1000);
            connection.setRequestMethod("GET");
            int statusCode = connection.getResponseCode();
            return statusCode >= 200 && statusCode < 400;
        }
        catch (IOException ignored)
        {
            return false;
        }
        finally
        {
            if (connection != null)
            {
                connection.disconnect();
            }
        }
    }

    private void destroyProcesses(RunPreviewSession session)
    {
        destroyProcess(session.adminFrontendProcess, session.adminFrontendPort);
        destroyProcess(session.frontendProcess, session.frontendPort);
        destroyProcess(session.backendProcess, session.backendPort);
    }

    private void destroyProcess(Process process, Integer port)
    {
        processKiller.stop(process, port);
    }

    private void startLogReader(final RunPreviewSession session, final Process process, final String name)
    {
        if (process == null)
        {
            return;
        }
        Thread thread = new Thread(new Runnable()
        {
            @Override
            public void run()
            {
                BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), Charset.forName("UTF-8")));
                try
                {
                    String line;
                    while ((line = reader.readLine()) != null)
                    {
                        appendProcessLog(session, name, line);
                    }
                }
                catch (IOException ignored)
                {
                    appendProcessLog(session, name, "log stream closed");
                }
            }
        });
        thread.setDaemon(true);
        thread.start();
    }

    private void appendLog(RunPreviewSession session, String line)
    {
        synchronized (session.logs)
        {
            session.logs.append(line).append('\n');
            if (session.logs.length() > MAX_LOG_LENGTH)
            {
                session.logs.delete(0, session.logs.length() - MAX_LOG_LENGTH);
            }
        }
    }

    private void appendProcessLog(RunPreviewSession session, String name, String line)
    {
        String formatted = "[" + name + "] " + line;
        appendLog(session, formatted);
        appendLog(logBuilder(session, name), line);
    }

    private StringBuilder logBuilder(RunPreviewSession session, String name)
    {
        if ("backend".equals(name))
        {
            return session.backendLogs;
        }
        if ("adminFrontend".equals(name))
        {
            return session.adminFrontendLogs;
        }
        return session.frontendLogs;
    }

    private void appendLog(StringBuilder logs, String line)
    {
        synchronized (logs)
        {
            logs.append(line).append('\n');
            if (logs.length() > MAX_LOG_LENGTH)
            {
                logs.delete(0, logs.length() - MAX_LOG_LENGTH);
            }
        }
    }

    private ProjectRunPreviewStatus notStarted(Long projectId)
    {
        ProjectRunPreviewStatus status = new ProjectRunPreviewStatus();
        status.setProjectId(projectId);
        status.setStatus(ProjectRunPreviewStatus.NOT_STARTED);
        status.setMessage("Preview has not been started");
        status.setUpdatedAt(now());
        status.setLogs("");
        status.setBackendLogs("");
        status.setFrontendLogs("");
        status.setAdminFrontendLogs("");
        return status;
    }

    private ProjectRunPreviewStatus toStatus(RunPreviewSession session)
    {
        ProjectRunPreviewStatus status = new ProjectRunPreviewStatus();
        status.setProjectId(session.projectId);
        status.setStatus(session.status);
        status.setMessage(session.message);
        status.setBackendUrl(session.backendUrl);
        status.setFrontendUrl(session.frontendUrl);
        status.setAdminFrontendUrl(session.adminFrontendUrl);
        status.setBackendPort(session.backendPort);
        status.setFrontendPort(session.frontendPort);
        status.setAdminFrontendPort(session.adminFrontendPort);
        status.setWorkspacePath(session.workspacePath);
        status.setDatabaseName(session.databaseName);
        status.setStartedAt(session.startedAt);
        status.setUpdatedAt(session.updatedAt);
        synchronized (session.logs)
        {
            status.setLogs(session.logs.toString());
        }
        synchronized (session.backendLogs)
        {
            status.setBackendLogs(session.backendLogs.toString());
        }
        synchronized (session.frontendLogs)
        {
            status.setFrontendLogs(session.frontendLogs.toString());
        }
        synchronized (session.adminFrontendLogs)
        {
            status.setAdminFrontendLogs(session.adminFrontendLogs.toString());
        }
        return status;
    }

    private String sessionKey(Long userId, Long projectId)
    {
        return userId + ":" + projectId;
    }

    private String now()
    {
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
    }

    private static class RunPreviewSession
    {
        private Long projectId;
        private String status;
        private String message;
        private String backendUrl;
        private String frontendUrl;
        private String adminFrontendUrl;
        private Integer backendPort;
        private Integer frontendPort;
        private Integer adminFrontendPort;
        private String workspacePath;
        private String databaseName;
        private String startedAt;
        private String updatedAt;
        private Process backendProcess;
        private Process frontendProcess;
        private Process adminFrontendProcess;
        private final StringBuilder logs = new StringBuilder();
        private final StringBuilder backendLogs = new StringBuilder();
        private final StringBuilder frontendLogs = new StringBuilder();
        private final StringBuilder adminFrontendLogs = new StringBuilder();
    }
}
