package com.jetbrains.cef.remote;

import org.cef.CefSettings;
import org.cef.OS;
import org.cef.callback.CefSchemeRegistrar;
import org.cef.handler.CefAppHandler;
import org.cef.misc.CefLog;
import org.cef.misc.Utils;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.file.Path;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.function.BiConsumer;
import java.util.function.Consumer;

public class ServerStarter {
    private static final boolean KILL_SERVER_ON_SHUTDOWN = Utils.getBoolean("JCEF_KILL_SERVER_ON_SHUTDOWN");
    private static final boolean DISABLE_GPU = Utils.getBoolean("JCEF_DISABLE_GPU");
    private static final boolean TRACE = Utils.getBoolean("JCEF_TRACE_SERVERSTARTER");
    private static final boolean USE_PARAMS_FILE = Utils.getBoolean("JCEF_USE_PARAMS_FILE");
    private static final boolean USE_SHORT_CMDLINE_PREFIXES = Utils.getBoolean("JCEF_USE_SHORT_CMDLINE_PREFIXES", true);
    private static final int WAIT_START_LOOP_SLEEP_MS = Utils.getInteger("JCEF_WAIT_START_LOOP_SLEEP_MS", 200);
    private static final String SPACE = "_SPACESYMBOL_";

    static Map<String, Process> ourNativeServerProcesses = new HashMap<>();

    static {
        if (KILL_SERVER_ON_SHUTDOWN) {
            CefLog.Debug("All cef_server instances will be killed at JVM exit.");
            Thread task = new Thread(() -> {
                for (String servTransport: ourNativeServerProcesses.keySet()) {
                    Process p = ourNativeServerProcesses.get(servTransport);
                    if (p != null) {
                        p.destroyForcibly();
                        CefLog.Debug("Killed cef_server process [%s].", servTransport);
                    }
                }
            });
            Runtime.getRuntime().addShutdownHook(task);
        }
    }

    // Should be called in bg thread
    public static boolean startProcessAndWait(ThriftTransport thriftServer, CefAppHandler appHandler, String[] args, CefSettings settings, boolean deleteRootDir, long timeoutMs) {
        return startProcessAndWait(NativeServerManager.getServerExe(), thriftServer, appHandler, args, settings, deleteRootDir, null, timeoutMs);
    }

    // Should be called in bg thread
    public static boolean startProcessAndWait(File serverExe, ThriftTransport thriftServer, CefAppHandler appHandler, String[] args, CefSettings settings, boolean deleteRootDir, Map<String, String> env, long timeoutMs) {
        // Select log path
        String serverLogPath = Utils.getString("CEF_SERVER_LOG_PATH");
        if (serverLogPath == null || serverLogPath.trim().isEmpty())
            serverLogPath = CefLog.GetFilePath();

        // Select log level
        String serverLogLevel = Utils.getString("CEF_SERVER_LOG_LEVEL");
        if (serverLogLevel == null)
            serverLogLevel = NativeServerManager.ServerLogLevel.cef2native_str(CefLog.GetLogLevel());
        else {
            try {
                final int nLogLevel = Integer.parseInt(serverLogLevel);
                serverLogLevel = NativeServerManager.ServerLogLevel.nativeDesc(nLogLevel);
            } catch (NumberFormatException e) {
            }
        }

        return startProcessAndWait(serverExe, thriftServer, appHandler, args, settings, serverLogPath, serverLogLevel, deleteRootDir, env, timeoutMs);
    }
    // Should be called in bg thread
    public static boolean startProcessAndWait(File serverExe, ThriftTransport thriftServer, CefAppHandler appHandler, String[] args, CefSettings settings, String logPath, String logLevel, boolean deleteRootDir, Map<String, String> env, long timeoutMs) {
        if (serverExe == null)
            serverExe = NativeServerManager.getServerExe();
        if (serverExe == null) {
            CefLog.Error("Can't start native cef_server, file is null.");
            return false;
        }
        if (!serverExe.exists()) {
            CefLog.Error("Can't start native cef_server, file doesn't exist: %s", serverExe.getAbsolutePath());
            return false;
        }

        Integer exitVal = startAndWaitImpl(serverExe, thriftServer, appHandler, args, settings, logPath, logLevel, deleteRootDir, env, timeoutMs);
        if (exitVal != null) {
            if (exitVal == 101) {
                // CefInitialize returns false. Probably, JCEF cache dir is locked.
                final SimpleDateFormat f = new SimpleDateFormat("hh_mm_ss_SSS");
                final String newCacheDir = Path.of(System.getProperty("java.io.tmpdir")).resolve("cef_cache_" + thriftServer.toStringShort() + "_" + f.format(new Date())).toString();
                CefLog.Info("Try to restart cef_server with another cache_dir '%s'.", newCacheDir);
                if (settings == null)
                    settings = new CefSettings();
                settings.cache_path = newCacheDir;
                exitVal = startAndWaitImpl(serverExe, thriftServer, appHandler, args, settings, logPath, logLevel, true, env, timeoutMs);
            }
        }

        return exitVal == null;
    }

    private static void processArg(String arg, Consumer<String> visitor) {
        if (TRACE) CefLog.Debug("processArg: " + arg);

        boolean skip = arg.startsWith("--browser-subprocess-path=")
                || arg.startsWith("--main-bundle-path=")
                || arg.startsWith("--framework-dir-path=");
        if (skip)
            CefLog.Debug("Skip cmdline swintch '%s'", arg);
        else
            visitor.accept(arg);
    }

    private static void processSetting(String name, String value, BiConsumer<String, String> visitor) {
        if (TRACE) CefLog.Debug("processSetting: " + name + "=" + value);

        boolean skip = "browser_subprocess_path".equals(name)
                || "resources_dir_path".equals(name)
                || "locales_dir_path".equals(name);
        if (skip)
            CefLog.Debug("Skip setting %s=%s", name, value);
        else
            visitor.accept(name, value);
    }

    private static void processCustomSchemes(CefAppHandler appHandler, BiConsumer<String, Integer> visitor) {
        if (appHandler != null) {
            CefSchemeRegistrar collector = new CefSchemeRegistrar() {
                @Override
                public boolean addCustomScheme(String schemeName, boolean isStandard, boolean isLocal, boolean isDisplayIsolated, boolean isSecure, boolean isCorsEnabled, boolean isCspBypassing, boolean isFetchEnabled) {
                    int options = 0;
                    if (isStandard) options |= 1 << 0;
                    if (isLocal) options |= 1 << 1;
                    if (isDisplayIsolated) options |= 1 << 2;
                    if (isSecure) options |= 1 << 3;
                    if (isCorsEnabled) options |= 1 << 4;
                    if (isCspBypassing) options |= 1 << 5;
                    if (isFetchEnabled) options |= 1 << 6;
                    if (TRACE) CefLog.Debug("process CefCustomScheme: " + schemeName + "=" + options);
                    visitor.accept(schemeName, options);
                    return false;
                }
            };
            appHandler.onRegisterCustomSchemes(collector);
        }
    }

    private static void addSwitchesDisableGPU(Consumer<String> visitor) {
        if (DISABLE_GPU) {
            CefLog.Debug("Add disable GPU chromium switches.");
            visitor.accept("--disable-gpu");
            visitor.accept("--disable-gpu-compositing");
            visitor.accept("--disable-gpu-vsync");
            visitor.accept("--disable-software-rasterizer");
            visitor.accept("--disable-extensions");
        }
    }

    private static boolean isPrefixedArg(String arg) {
        final String[] ourPrefixes = new String[] { "arg:", "cmd_switch:" };
        for (String prefix: ourPrefixes)
            if (arg.startsWith(prefix))
                return true;
        return false;
    }

    private static boolean isPrefixedCS(String arg) {
        final String[] ourPrefixes = new String[] { "sch:", "customscheme:" };
        for (String prefix: ourPrefixes)
            if (arg.startsWith(prefix))
                return true;
        return false;
    }

    private static boolean isPrefixedSetting(String arg) {
        final String[] ourPrefixes = new String[] { "cs:", "cef_setting:" };
        for (String prefix: ourPrefixes)
            if (arg.startsWith(prefix))
                return true;
        return false;
    }


    private static void prepareStartParamsWithCmdLine(ProcessBuilder builder, File serverExe, CefAppHandler appHandler, String[] args, CefSettings settings) {
        // Use prefixed command line switches, CefSettings and custom schemes (from CefAppHandler).

        // 1. command line args
        List<String> prefixedArgs = new ArrayList<>();
        List<String> prefixedSettings = new ArrayList<>();
        List<String> prefixedCS = new ArrayList<>();
        String prefix = USE_SHORT_CMDLINE_PREFIXES ? "arg:" : "cmd_switch:";
        if (args != null && args.length > 0)
            for (String arg: args) {
                if (isPrefixedArg(arg)) {
                    prefixedArgs.add(arg);
                    continue;
                }
                if (isPrefixedCS(arg)) {
                    prefixedCS.add(arg);
                    continue;
                }
                if (isPrefixedSetting(arg)) {
                    prefixedSettings.add(arg);
                    continue;
                }
                String finalPrefix = prefix;
                processArg(arg, s -> prefixedArgs.add(finalPrefix + s.replace(" ", SPACE)));
            }

        if (DISABLE_GPU) {
            String finalPrefix = prefix;
            addSwitchesDisableGPU(s -> prefixedArgs.add(finalPrefix + s));
        }

        // 2. settings
        prefix = USE_SHORT_CMDLINE_PREFIXES ? "cs:" : "cef_setting:";
        if (settings != null) {
            Map<String, String> settingsMap = settings.toMap();
            for (Map.Entry<String, String> entry : settingsMap.entrySet()) {
                String finalPrefix = prefix;
                processSetting(entry.getKey(), entry.getValue(), (n, v) -> prefixedSettings.add(finalPrefix + n + "=" + v.replace(" ", SPACE)));
            }
        }

        if (OS.isMacintosh() && serverExe != null) {
            File subprocess = new File(serverExe.getParentFile().getParentFile(), "Frameworks/cef_server Helper.app/Contents/MacOS/cef_server Helper");
            if (settings != null && settings.browser_subprocess_path != null)
                CefLog.Debug("browser_subprocess_path setting '%s' will be overridden with bundled path '%s'", settings.browser_subprocess_path, subprocess.getAbsolutePath());
            prefixedSettings.add(prefix + "browser_subprocess_path=" + subprocess.getAbsolutePath().replace(" ", SPACE));
        }

        // 3. custom schemes
        String finalPrefix = USE_SHORT_CMDLINE_PREFIXES ? "sch:" : "customscheme:";
        processCustomSchemes(appHandler, (n,o) -> prefixedCS.add(finalPrefix + n + "=" + o));

        // 4. add results to builder's command line args
        prefixedArgs.forEach(builder.command()::add);
        prefixedSettings.forEach(builder.command()::add);
        prefixedCS.forEach(builder.command()::add);
    }

    private static boolean prepareStartParamsWithFile(ProcessBuilder builder, File serverExe, CefAppHandler appHandler, String[] args, CefSettings settings) {
        // Write chromium command line switches, CefSettings and custom schemes to the params file.

        final long t0 = System.nanoTime();
        final Path settingsFileName = Path.of(System.getProperty("java.io.tmpdir")).resolve("cef_server_params.txt");
        File f = new File(settingsFileName.toString());
        PrintStream ps;
        try {
            new FileOutputStream(f).close(); // delete the content of the file
            f.createNewFile();
            ps = new PrintStream(new FileOutputStream(f, false));
        } catch (IOException e) {
            CefLog.Error("Can't create temp file with server params path=%s, msg=%s", settingsFileName.toString(), e.getMessage());
            return false;
        }

        // 1. command line args
        final String sectionCmdLine = "[COMMAND_LINE]:";
        ps.printf("%s\n", sectionCmdLine);
        if (args != null && args.length > 0)
            for (String arg: args)
                processArg(arg, s -> ps.printf("%s\n", s));

        if (DISABLE_GPU)
            addSwitchesDisableGPU(s -> ps.println(s));

        // 2. settings
        ps.printf("[SETTINGS]:\n");
        if (settings != null) {
            Map<String, String> settingsMap = settings.toMap();
            for (Map.Entry<String, String> entry : settingsMap.entrySet())
                processSetting(entry.getKey(), entry.getValue(), (n,v) -> ps.printf("%s=%s\n", n, v));
        }

        if (OS.isMacintosh() && serverExe != null) {
            File subprocess = new File(serverExe.getParentFile().getParentFile(), "Frameworks/cef_server Helper.app/Contents/MacOS/cef_server Helper");
            if (settings != null && settings.browser_subprocess_path != null)
                CefLog.Debug("browser_subprocess_path setting '%s' will be overridden with bundled path '%s'", settings.browser_subprocess_path, subprocess.getAbsolutePath());
            ps.printf("browser_subprocess_path=%s\n", subprocess.getAbsolutePath());
        }

        // 3. custom schemes
        ps.printf("[CUSTOM_SCHEMES]:\n");
        processCustomSchemes(appHandler, (n,o) -> ps.printf("%s|%d\n", n, o));

        ps.flush();
        ps.close();

        CefLog.Debug("Settings were written to file, spent %d mcs", (System.nanoTime() - t0)/1000);
        builder.command().add(String.format("--params=%s", f.getAbsolutePath()));
        return true;
    }

    // Returns:
    // null when the process has been started successfully
    // Integer.MIN_VALUE when can't start process because of IO-errors
    // exit code, otherwise
    private static Integer startAndWaitImpl(File serverExe, ThriftTransport thriftServer, CefAppHandler appHandler, String[] args, CefSettings settings, String logPath, String logLevel, boolean deleteRootDir, Map<String, String> env, long timeoutMs) {
        final long t0 = System.nanoTime();
        CefLog.Info("Start native cef_server with cache path: %s", settings == null ? null : settings.cache_path);
        CefLog.Debug("cef_server executable path='%s'", serverExe.getAbsolutePath());

        if (ourNativeServerProcesses.get(thriftServer.toString()) != null)
            CefLog.Debug("Handle of server process will be overwritten.");
        ourNativeServerProcesses.remove(thriftServer.toString());

        ProcessBuilder builder = new ProcessBuilder(serverExe.getAbsolutePath());
        CefLog.Debug("\tWorking dir %s", serverExe.getParentFile());
        builder.directory(serverExe.getParentFile());
        builder.redirectOutput(ProcessBuilder.Redirect.INHERIT);
        builder.redirectError(ProcessBuilder.Redirect.INHERIT);

        if (env != null && env.size() > 0) {
            builder.environment().putAll(env);
            if (CefLog.IsDebugEnabled()) {
                String logTxt = "";
                for (Map.Entry<String, String> entry : env.entrySet()) {
                    if (logTxt.length() > 0)
                        logTxt += "; ";
                    logTxt += entry.getKey() + "=" + entry.getValue();
                }
                CefLog.Debug("\tEnvironment vars: %s", logTxt);
            }
        }

        if (thriftServer.isTcp()) {
            CefLog.Debug("\tUse tcp-port %d", thriftServer.getPort());
            builder.command().add(String.format("--port=%d", thriftServer.getPort()));
        } else {
            CefLog.Debug("\tUse pipe %s", thriftServer.getPipe());
            builder.command().add(String.format("--pipe=%s", thriftServer.getPipe()));
        }
        String logStream = "stderr";
        if (logPath != null && !logPath.isEmpty()) {
            logStream = "file '" + logPath + "'";
            builder.command().add(String.format("--logfile=%s", logPath.trim()));
        }

        CefLog.Info("Native server logging: level '%s', stream: '%s'", logLevel, logStream);
        builder.command().add(String.format("--loglevel=%s", logLevel));

        if (System.getenv().containsKey("DEBUG_CEF_SERVER"))
            builder.command().add("--cef-server-wait-debugger");

        if (deleteRootDir)
            builder.command().add("--deleteRootCacheDir");

        if (!USE_PARAMS_FILE
                || !prepareStartParamsWithFile(builder, serverExe, appHandler, args, settings))
        {
            prepareStartParamsWithCmdLine(builder, serverExe, appHandler, args, settings);
        }

        Process p;
        try {
            p = builder.start();
            ourNativeServerProcesses.put(thriftServer.toString(), p);
        } catch (Throwable e) {
            CefLog.Error("Can't start native cef_server, exception: %s", e.getMessage());
            return Integer.MIN_VALUE;
        }

        // Wait for native server
        Integer exitVal = null;
        boolean running = false;
        final long t1 = System.nanoTime();
        do {
            try {
                Thread.sleep(WAIT_START_LOOP_SLEEP_MS);
            } catch (InterruptedException e) {
                CefLog.Error("Exception during waiting for native cef_server: %s", e.getMessage());
            }
            CefLog.Debug("Waiting for server %s starting...", thriftServer.toStringShort());
            // 1. Check process exit values.
            try {
                exitVal = p.exitValue();
            } catch (IllegalThreadStateException e) {}

            if (exitVal != null) {
                CefLog.Error("Native cef_server exited with code %d", exitVal);
                if (exitVal == 100) {
                    CefLog.Error("It means that cef_server can't load CEF framework library.");
                } else if (exitVal == 101) {
                    CefLog.Error("It means that CefInitialize returns false - probably, JCEF cache dir is locked.");
                    // TODO: search stdout for string 'Opening in existing browser session'
                }

                ourNativeServerProcesses.remove(thriftServer.toString());
                return exitVal;
            }

            // 2. Try to connect with cef_server.
            running = NativeServerManager.isRunning(thriftServer) != null;
        } while (!running && (System.nanoTime() - t1 < timeoutMs*1000000));

        // Check whether the server is running or not.
        if (!running && !(running = (NativeServerManager.isRunning(thriftServer, true) != null))) {
            if (p.isAlive())
                CefLog.Error("Native cef_server was started but client can't connect.");
            else {
                CefLog.Error("Can't start native cef_server, process is dead.");
                ourNativeServerProcesses.remove(thriftServer.toString());
            }
            try {
                exitVal = p.exitValue();
            } catch (IllegalThreadStateException e) {}
        } else
            CefLog.Debug("Server is started. Spent ms: process starting %d, waiting %d", (t1 - t0)/1000000, (System.nanoTime() - t1)/1000000);
        return running ? null : exitVal;
    }
}
