package io.xpipe.app.terminal;

import io.xpipe.app.core.AppInstallation;
import io.xpipe.app.core.AppNames;
import io.xpipe.app.issue.ErrorEventFactory;
import io.xpipe.app.prefs.AppPrefs;
import io.xpipe.app.process.*;
import io.xpipe.app.storage.DataStorage;
import io.xpipe.app.storage.DataStoreEntry;
import io.xpipe.app.util.ThreadHelper;
import io.xpipe.app.util.FailableFunction;
import io.xpipe.app.util.FilePath;
import io.xpipe.app.util.OsType;

import lombok.Value;

import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;

public class TerminalLauncher {

    private static Instant lastLaunch = Instant.EPOCH;

    public static FilePath constructTerminalInitFile(
            ShellDialect t,
            ShellControl processControl,
            WorkingDirectoryFunction workingDirectory,
            List<String> preInit,
            List<String> postInit,
            TerminalInitScriptConfig config,
            boolean exit)
            throws Exception {
        var content = constructTerminalInitScript(t, processControl, workingDirectory, preInit, postInit, config, exit);
        var hash = ScriptHelper.getScriptHash(processControl, content);
        var file = t.getInitFileName(processControl, hash);
        return ScriptHelper.createExecScriptRaw(processControl, file, content, true);
    }

    private static String constructTerminalInitScript(
            ShellDialect t,
            ShellControl processControl,
            WorkingDirectoryFunction workingDirectory,
            List<String> preInit,
            List<String> postInit,
            TerminalInitScriptConfig config,
            boolean exit)
            throws Exception {
        String nl = t.getNewLine().getNewLineString();
        var content = "";

        var clear = t.clearDisplayCommand();
        if (clear != null && config.isClearScreen()) {
            content += clear + nl;
        }

        // Normalize line endings
        content += nl + preInit.stream().flatMap(s -> s.lines()).collect(Collectors.joining(nl)) + nl;

        // We just apply the profile files always, as we can't be sure that they definitely have been applied.
        // Especially if we launch something that is not the system default shell
        var applyCommand = t.applyInitFileCommand(processControl);
        if (applyCommand != null) {
            content += nl + applyCommand + nl;
        }

        if (config.getDisplayName() != null) {
            content += nl + t.changeTitleCommand(config.getDisplayName()) + nl;
        }

        if (workingDirectory != null && workingDirectory.isSpecified()) {
            var wd = workingDirectory.apply(processControl);
            if (wd != null) {
                content += t.getCdCommand(wd.toString()) + nl;
            }
        }

        // Normalize line endings
        content += nl + postInit.stream().flatMap(s -> s.lines()).collect(Collectors.joining(nl)) + nl;

        if (exit) {
            content += nl + t.getPassthroughExitCommand();
        }

        content = t.prepareScriptContent(processControl, content);
        return content;
    }

    static void openDirect(
            String title, FailableFunction<ShellControl, ShellScript, Exception> command, ExternalTerminalType type)
            throws Exception {
        try (var sc = LocalShell.getShell().start()) {
            var script = constructTerminalInitScript(
                    sc.getShellDialect(),
                    sc,
                    WorkingDirectoryFunction.none(),
                    List.of(),
                    List.of(command.apply(sc).toString()),
                    new TerminalInitScriptConfig(
                            title,
                            type.shouldClear()
                                    && AppPrefs.get().clearTerminalOnInit().get()
                                    && !AppPrefs.get().developerPrintInitFiles().get(),
                            TerminalInitFunction.none()),
                    true);
            var singlePane = new TerminalPaneConfiguration(UUID.randomUUID(), title, 0, script, sc.getShellDialect());
            var config = new TerminalLaunchConfiguration(null, title, title, true, false, List.of(singlePane));
            launch(type, config);
        }
    }

    @Value
    public static class Config {
        DataStoreEntry entry;
        String title;
        FilePath directory;
        UUID request;
        boolean enableLogging;
        boolean alwaysKeepOpen;
        ProcessControl processControl;
    }

    public static void open(List<Config> configs, boolean preferTabs, ExternalTerminalType type) throws Exception {
        var paneList = new ArrayList<TerminalPaneConfiguration>();
        for (Config config : configs) {
            var entry = config.getEntry();
            var color = entry != null ? DataStorage.get().getEffectiveColor(entry) : null;
            var prefix = entry != null && color != null && type.useColoredTitle() ? color.getEmoji() + " " : "";
            var cleanTitle =
                    (config.getTitle() != null ? config.getTitle() : entry != null ? entry.getName() : "Unknown");
            var adjustedTitle = prefix + cleanTitle;

            var log = config.isEnableLogging()
                    && AppPrefs.get().enableTerminalLogging().get();
            var terminalConfig = new TerminalInitScriptConfig(
                    adjustedTitle,
                    !log
                            && type.shouldClear()
                            && AppPrefs.get().clearTerminalOnInit().get()
                            && !AppPrefs.get().developerPrintInitFiles().get(),
                    config.getProcessControl() instanceof ShellControl
                            ? type.additionalInitCommands()
                            : TerminalInitFunction.none());
            TerminalLauncherManager.submitAsync(
                    config.getRequest(), config.getProcessControl(), terminalConfig, config.getDirectory());

            var paneIndex = configs.indexOf(config);
            var paneConfig = TerminalPaneConfiguration.create(
                    config.getRequest(), entry, config.getTitle(), paneIndex, log, config.isAlwaysKeepOpen());
            paneList.add(paneConfig);
        }

        var title = configs.size() == 1
                ? configs.getFirst().getTitle()
                : AppNames.ofCurrent().getName() + " (" + configs.size() + " tabs)";
        var entry = configs.size() == 1 ? configs.getFirst().getEntry() : null;
        var color = entry != null ? DataStorage.get().getEffectiveColor(entry) : null;
        var prefix = entry != null && color != null && type.useColoredTitle() ? color.getEmoji() + " " : "";
        var cleanTitle = (title != null ? title : entry != null ? entry.getName() : "Unknown");
        var adjustedTitle = prefix + cleanTitle;
        var effectivePreferTabs =
                preferTabs && AppPrefs.get().preferTerminalTabs().get();
        var dock = effectivePreferTabs
                && AppPrefs.get().enableConnectionHubTerminalDocking().get()
                && TerminalDockHubManager.isAvailable();
        var launchConfig =
                new TerminalLaunchConfiguration(color, adjustedTitle, cleanTitle, effectivePreferTabs, dock, paneList);

        if (dock) {
            // Dock terminal if needed
            for (TerminalPaneConfiguration pane : launchConfig.getPanes()) {
                TerminalDockHubManager.get().openTerminal(pane.getRequest());
            }
        }

        if (effectivePreferTabs) {
            // There will be timing issues when launching multiple tabs in a short time span
            TerminalMultiplexerManager.waitForMultiplexerStartup();

            // Synchronize between multiple existing tab launches as some terminals and multiplexers might break there
            long elapsed;
            synchronized (TerminalLauncher.class) {
                elapsed = Duration.between(lastLaunch, Instant.now()).toMillis();
                lastLaunch = Instant.now();
            }
            if (elapsed < 1000) {
                ThreadHelper.sleep(1000 - elapsed);
            }

            // Track sessions that are used for the multiplexer
            // Used to figure out when it dies
            TerminalMultiplexerManager.registerSessionLaunch(launchConfig);

            if (launchMultiplexerTabInExistingTerminal(launchConfig)) {
                return;
            }

            var multiplexerConfig = launchMultiplexerTabInNewTerminal(launchConfig);
            if (multiplexerConfig.isPresent()) {
                launch(type, multiplexerConfig.get());
                return;
            }
        }

        var proxyConfig = launchProxy(launchConfig);
        if (proxyConfig.isPresent()) {
            launch(type, proxyConfig.get());
            return;
        }

        launch(type, launchConfig);
    }

    private static void launch(ExternalTerminalType type, TerminalLaunchConfiguration config) throws Exception {
        if (type == null) {
            return;
        }

        try {
            type.launch(config);
        } catch (Exception ex) {
            var modMsg = ex.getMessage() != null && ex.getMessage().contains("Unable to find application named")
                    ? ex.getMessage() + " in installed /Applications on this system"
                    : ex.getMessage();
            throw ErrorEventFactory.expected(new IOException(
                    "Unable to launch terminal " + type.toTranslatedString().getValue() + ": " + modMsg, ex));
        }
    }

    private static boolean launchMultiplexerTabInExistingTerminal(TerminalLaunchConfiguration launchConfiguration)
            throws Exception {
        var multiplexer = TerminalMultiplexerManager.getEffectiveMultiplexer();
        if (multiplexer.isEmpty()) {
            return false;
        }

        var control = TerminalProxyManager.getProxy().orElse(LocalShell.getShell());

        // Throw if not supported
        multiplexer.get().checkSupported(control);

        var session = TerminalMultiplexerManager.getActiveMultiplexerSession();
        if (session.isEmpty()) {
            return false;
        }

        // Map panes to original multiplexer request session
        var mapped = TerminalMultiplexerManager.getActiveMultiplexerContainerRequest();
        if (mapped.isPresent()) {
            for (TerminalPaneConfiguration pane : launchConfiguration.getPanes()) {
                TerminalView.get().addSubstitution(pane.getRequest(), mapped.get());
            }
        }

        var multiplexerCommandString = multiplexer
                .get()
                .launchForExistingSession(control, launchConfiguration)
                .toString();
        CommandControl multiplexerCommand = control.command(multiplexerCommandString);
        // Multiplexer might freeze
        multiplexerCommand.killOnTimeout(CountDown.of().start(10000));
        multiplexerCommand.execute();
        TerminalView.focus(session.get());
        return true;
    }

    private static Optional<TerminalLaunchConfiguration> launchMultiplexerTabInNewTerminal(
            TerminalLaunchConfiguration launchConfiguration) throws Exception {
        var multiplexer = TerminalMultiplexerManager.getEffectiveMultiplexer();
        if (multiplexer.isEmpty()) {
            return Optional.empty();
        }

        // Throw if not supported
        multiplexer.get().checkSupported(TerminalProxyManager.getProxy().orElse(LocalShell.getShell()));

        if (TerminalMultiplexerManager.getActiveMultiplexerContainerRequest().isPresent()) {
            return Optional.empty();
        }

        var multiplexerContainerLaunchRequest = UUID.randomUUID();
        // Map panes to original multiplexer request session
        for (TerminalPaneConfiguration pane : launchConfiguration.getPanes()) {
            TerminalView.get().addSubstitution(pane.getRequest(), multiplexerContainerLaunchRequest);
        }
        // Use initial shell session to track when multiplexer has started up
        TerminalMultiplexerManager.registerMultiplexerContainerLaunch(multiplexerContainerLaunchRequest);

        var multiplexerTabLaunchRequest = UUID.randomUUID();

        var proxyControl = TerminalProxyManager.getProxy();
        if (proxyControl.isPresent()) {
            var proxyMultiplexerCommand = multiplexer
                    .get()
                    .launchNewSession(proxyControl.get(), launchConfiguration)
                    .toString();
            var proxyLaunchCommand = proxyControl
                    .get()
                    .prepareIntermediateTerminalOpen(
                            TerminalInitFunction.fixed(proxyMultiplexerCommand),
                            TerminalInitScriptConfig.ofName(AppNames.ofCurrent().getName()),
                            WorkingDirectoryFunction.none());
            // Restart for the next time
            proxyControl.get().start();
            var fullLocalCommand = getTerminalRegisterCommand(multiplexerContainerLaunchRequest, LocalShell.getShell())
                    + "\n" + proxyLaunchCommand;
            var pane = new TerminalPaneConfiguration(
                    multiplexerTabLaunchRequest,
                    AppNames.ofCurrent().getName(),
                    0,
                    fullLocalCommand,
                    LocalShell.getDialect());
            return Optional.of(new TerminalLaunchConfiguration(
                    null,
                    AppNames.ofCurrent().getName(),
                    AppNames.ofCurrent().getName(),
                    false,
                    launchConfiguration.isDock(),
                    List.of(pane)));
        } else {
            var multiplexerCommand = multiplexer
                    .get()
                    .launchNewSession(LocalShell.getShell(), launchConfiguration)
                    .toString();
            var launchCommand = LocalShell.getShell()
                    .prepareIntermediateTerminalOpen(
                            TerminalInitFunction.fixed(multiplexerCommand),
                            TerminalInitScriptConfig.ofName(AppNames.ofCurrent().getName()),
                            WorkingDirectoryFunction.none());
            var fullLocalCommand = getTerminalRegisterCommand(multiplexerContainerLaunchRequest, LocalShell.getShell())
                    + "\n" + launchCommand;
            var pane = new TerminalPaneConfiguration(
                    multiplexerTabLaunchRequest,
                    AppNames.ofCurrent().getName(),
                    0,
                    fullLocalCommand,
                    LocalShell.getDialect());
            return Optional.of(new TerminalLaunchConfiguration(
                    null,
                    AppNames.ofCurrent().getName(),
                    AppNames.ofCurrent().getName(),
                    false,
                    launchConfiguration.isDock(),
                    List.of(pane)));
        }
    }

    private static Optional<TerminalLaunchConfiguration> launchProxy(TerminalLaunchConfiguration launchConfiguration)
            throws Exception {
        var proxyControl = TerminalProxyManager.getProxy();
        if (proxyControl.isEmpty()) {
            return Optional.empty();
        }

        // We can't track sessions inside another environment, so map the ids to the proxy container process
        var proxyContainerLaunchRequest = UUID.randomUUID();
        for (TerminalPaneConfiguration pane : launchConfiguration.getPanes()) {
            TerminalView.get().addSubstitution(pane.getRequest(), proxyContainerLaunchRequest);
        }

        var panes = new ArrayList<TerminalPaneConfiguration>();
        for (TerminalPaneConfiguration pane : launchConfiguration.getPanes()) {
            var openCommand = pane.getDialectLaunchCommand().buildSimple();
            var launchCommand = proxyControl
                    .get()
                    .prepareIntermediateTerminalOpen(
                            TerminalInitFunction.fixed(openCommand),
                            TerminalInitScriptConfig.ofName(AppNames.ofCurrent().getName()),
                            WorkingDirectoryFunction.none());
            var fullLocalCommand = getTerminalRegisterCommand(proxyContainerLaunchRequest, LocalShell.getShell()) + "\n"
                    + launchCommand;
            // Restart for the next time
            proxyControl.get().start();
            panes.add(pane.withScript(LocalShell.getDialect(), fullLocalCommand));
        }

        return Optional.ofNullable(launchConfiguration.withPanes(panes));
    }

    public static String getTerminalRegisterCommand(UUID request, ShellControl sc) throws Exception {
        var exec = AppInstallation.ofCurrent().getCliExecutablePath();
        var registerLine = CommandBuilder.of()
                .addFile(sc.getLocalSystemAccess().translateFromLocalSystemPath(FilePath.of(exec)))
                .add("terminal-register", "--request", request.toString())
                .buildSimple();
        var powershell = ShellDialects.isPowershell(sc);
        var bellLine = "printf \"\\a\"";
        var printBell = OsType.ofLocal() != OsType.WINDOWS
                && AppPrefs.get().enableTerminalStartupBell().get();
        var lines = ShellScript.lines((powershell ? "& " + registerLine : registerLine), printBell ? bellLine : null);
        return lines.toString();
    }
}
