/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

package org.netbeans.modules.javascript.karma.exec;

import java.io.File;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.netbeans.api.annotations.common.CheckForNull;
import org.netbeans.api.extexecution.ExecutionDescriptor;
import org.netbeans.api.extexecution.print.ConvertedLine;
import org.netbeans.api.extexecution.print.LineConvertor;
import org.netbeans.api.options.OptionsDisplayer;
import org.netbeans.api.project.Project;
import org.netbeans.api.project.ProjectUtils;
import org.netbeans.modules.javascript.karma.browsers.Browser;
import org.netbeans.modules.javascript.karma.browsers.Browsers;
import org.netbeans.modules.javascript.karma.options.KarmaOptions;
import org.netbeans.modules.javascript.karma.options.KarmaOptionsValidator;
import org.netbeans.modules.javascript.karma.preferences.KarmaPreferencesValidator;
import org.netbeans.modules.javascript.karma.run.KarmaRunInfo;
import org.netbeans.modules.javascript.karma.run.TestRunner;
import org.netbeans.modules.javascript.karma.ui.KarmaErrorsDialog;
import org.netbeans.modules.javascript.karma.ui.options.KarmaOptionsPanelController;
import org.netbeans.modules.javascript.karma.util.FileUtils;
import org.netbeans.modules.javascript.karma.util.StringUtils;
import org.netbeans.modules.web.clientproject.api.jstesting.JsTestingProviders;
import org.netbeans.modules.web.common.api.ValidationResult;
import org.netbeans.modules.web.common.ui.api.ExternalExecutable;
import org.netbeans.spi.project.ui.CustomizerProvider2;
import org.openide.filesystems.FileUtil;
import org.openide.util.NbBundle;
import org.openide.util.Pair;
import org.openide.util.RequestProcessor;
import org.openide.util.Utilities;
import org.openide.windows.InputOutput;
import org.openide.windows.OutputEvent;
import org.openide.windows.OutputListener;

public class KarmaExecutable {

    private static final Logger LOGGER = Logger.getLogger(KarmaExecutable.class.getName());

    public static final String KARMA_NAME;

    private static final String START_COMMAND = "start";
    private static final String RUN_COMMAND = "run";
    private static final String PORT_PARAMETER = "--port";

    protected final String karmaPath;
    protected final Project project;


    static {
        if (Utilities.isWindows()) {
            KARMA_NAME = "karma.cmd"; // NOI18N
        } else {
            KARMA_NAME = "karma"; // NOI18N
        }
    }


    KarmaExecutable(String karmaPath, Project project) {
        assert karmaPath != null;
        assert project != null;
        this.karmaPath = karmaPath;
        this.project = project;
    }

    @CheckForNull
    public static KarmaExecutable getDefault(Project project, boolean showOptionsOrCustomizer) {
        assert project != null;
        // options
        ValidationResult result = new KarmaOptionsValidator()
                .validateKarma()
                .getResult();
        if (validateResult(result) != null) {
            if (showOptionsOrCustomizer) {
                OptionsDisplayer.getDefault().open(KarmaOptionsPanelController.OPTIONS_PATH);
            }
            return null;
        }
        // customizer
        result = new KarmaPreferencesValidator()
                .validate(project)
                .getResult();
        if (validateResult(result) != null) {
            if (showOptionsOrCustomizer) {
                project.getLookup().lookup(CustomizerProvider2.class).showCustomizer(JsTestingProviders.CUSTOMIZER_IDENT, null);
            }
            return null;
        }
        return createExecutable(KarmaOptions.getInstance().getKarma(), project);
    }

    private static KarmaExecutable createExecutable(String karma, Project project) {
        if (Utilities.isMac()) {
            return new MacKarmaExecutable(karma, project);
        }
        return new KarmaExecutable(karma, project);
    }

    @NbBundle.Messages({
        "# {0} - project name",
        "KarmaExecutable.start=Karma ({0})",
    })
    @CheckForNull
    public Future<Integer> start(int port, KarmaRunInfo karmaRunInfo) {
        final CountDownLatch countDownLatch = new CountDownLatch(1);
        Runnable countDownTask = new Runnable() {
            @Override
            public void run() {
                countDownLatch.countDown();
            }
        };
        Future<Integer> task = getExecutable(Bundle.KarmaExecutable_start(ProjectUtils.getInformation(project).getDisplayName()), getProjectDir())
                .additionalParameters(getStartParams(port, karmaRunInfo))
                .environmentVariables(karmaRunInfo.getEnvVars())
                .run(getStartDescriptor(karmaRunInfo, countDownTask));
        assert task != null : karmaPath;
        try {
            countDownLatch.await(15, TimeUnit.SECONDS);
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
        }
        if (task.isDone()) {
            // some error, task is not running
            return null;
        }
        return task;
    }

    public void runTests(int port) {
        getExecutable("karma run...", getProjectDir()) // NOI18N
                .additionalParameters(getRunParams(port))
                // XXX wait?
                .run(getRunDescriptor());
    }

    private File getProjectDir() {
        return FileUtil.toFile(project.getProjectDirectory());
    }

    String getCommand() {
        return karmaPath;
    }

    private ExternalExecutable getExecutable(String title, File workDir) {
        return new ExternalExecutable(getCommand())
                .workDir(workDir)
                .displayName(title)
                .noOutput(false);
    }

    private ExecutionDescriptor getStartDescriptor(KarmaRunInfo karmaRunInfo, Runnable serverStartTask) {
        return new ExecutionDescriptor()
                .frontWindow(false)
                .frontWindowOnError(false)
                .outLineBased(true)
                .errLineBased(true)
                .outConvertorFactory(new ServerLineConvertorFactory(karmaRunInfo, serverStartTask))
                .postExecution(serverStartTask);
    }

    private ExecutionDescriptor getRunDescriptor() {
        return new ExecutionDescriptor()
                .inputOutput(InputOutput.NULL);
    }

    List<String> getStartParams(int port, KarmaRunInfo karmaRunInfo) {
        List<String> params = new ArrayList<>(4);
        params.add(START_COMMAND);
        params.add(karmaRunInfo.getNbConfigFile());
        params.add(PORT_PARAMETER);
        params.add(Integer.toString(port));
        return params;
    }

    List<String> getRunParams(int port) {
        List<String> params = new ArrayList<>(3);
        params.add(RUN_COMMAND);
        params.add(PORT_PARAMETER);
        params.add(Integer.toString(port));
        return params;
    }

    @CheckForNull
    private static String validateResult(ValidationResult result) {
        if (result.isFaultless()) {
            return null;
        }
        if (result.hasErrors()) {
            return result.getErrors().get(0).getMessage();
        }
        return result.getWarnings().get(0).getMessage();
    }

    //~ Inner classes

    // #238974
    private static final class MacKarmaExecutable extends KarmaExecutable {

        private static final String BASH_COMMAND = "/bin/bash -lc"; // NOI18N


        MacKarmaExecutable(String karmaPath, Project project) {
            super(karmaPath, project);
        }

        @Override
        String getCommand() {
            return BASH_COMMAND;
        }

        @Override
        List<String> getStartParams(int port, KarmaRunInfo karmaRunInfo) {
            return getParams(super.getStartParams(port, karmaRunInfo));
        }

        @Override
        List<String> getRunParams(int port) {
            return getParams(super.getRunParams(port));
        }

        private List<String> getParams(List<String> originalParams) {
            StringBuilder sb = new StringBuilder(200);
            sb.append("\""); // NOI18N
            sb.append(karmaPath);
            sb.append("\" \""); // NOI18N
            sb.append(StringUtils.implode(originalParams, "\" \"")); // NOI18N
            sb.append("\""); // NOI18N
            return Collections.singletonList(sb.toString());
        }

    }

    private static final class ServerLineConvertorFactory implements ExecutionDescriptor.LineConvertorFactory {

        private final LineConvertor serverLineConvertor;


        public ServerLineConvertorFactory(KarmaRunInfo karmaRunInfo, Runnable startFinishedTask) {
            assert karmaRunInfo != null;
            assert startFinishedTask != null;
            serverLineConvertor = new ServerLineConvertor(karmaRunInfo, startFinishedTask);
        }

        @Override
        public LineConvertor newLineConvertor() {
            return serverLineConvertor;
        }

    }

    private static final class ServerLineConvertor implements LineConvertor {

        private static final boolean DEBUG = Boolean.getBoolean("nb.karma.debug"); // NOI18N

        private static final String NB_BROWSERS = "$NB$netbeans browsers "; // NOI18N
        private static final String KARMA_ERROR = "[31mERROR ["; // NOI18N
        private static final String KARMA_WARN = "[33mWARN ["; // NOI18N

        private final KarmaRunInfo karmaRunInfo;
        private final Runnable startFinishedTask;
        private final TestRunner testRunner;

        private boolean firstLine = true;
        private boolean startFinishedTaskRun = false;
        private Collection<Browser> browsers = null;
        private int browserCount = -1;
        private int connectedBrowsers = 0;


        public ServerLineConvertor(KarmaRunInfo karmaRunInfo, Runnable startFinishedTask) {
            assert karmaRunInfo != null;
            assert startFinishedTask != null;
            this.karmaRunInfo = karmaRunInfo;
            this.startFinishedTask = startFinishedTask;
            testRunner = new TestRunner(karmaRunInfo);
        }

        @Override
        public List<ConvertedLine> convert(String line) {
            // info
            if (firstLine
                    && line.contains(karmaRunInfo.getNbConfigFile())) {
                firstLine = false;
                return Collections.singletonList(ConvertedLine.forText(
                        line.replace(karmaRunInfo.getNbConfigFile(), karmaRunInfo.getProjectConfigFile()), null));
            }
            // startup
            if (browsers == null) {
                // server start
                if (line.startsWith(NB_BROWSERS)) {
                    List<String> allBrowsers = StringUtils.explode(line.substring(NB_BROWSERS.length()), ","); // NOI18N
                    browserCount = allBrowsers.size();
                    browsers = Browsers.getBrowsers(allBrowsers);
                    return Collections.emptyList();
                }
            }
            if (startFinishedTask != null
                    && !startFinishedTaskRun
                    && line.contains("Connected on socket")) { // NOI18N
                assert browsers != null;
                connectedBrowsers++;
                if (connectedBrowsers == browserCount) {
                    startFinishedTask.run();
                    startFinishedTaskRun = true;
                }
            } else if (line.startsWith(TestRunner.NB_LINE)) {
                // test result
                testRunner.process(line);
                if (DEBUG) {
                    return Collections.singletonList(ConvertedLine.forText(line, null));
                }
                return Collections.emptyList();
            }
            // some error before browser startup?
            if (connectedBrowsers < browserCount
                    && (line.contains(KARMA_ERROR) || line.contains(KARMA_WARN))) {
                KarmaErrorsDialog.getInstance().show();
            }
            // process output
            OutputListener outputListener = null;
            if (browsers == null) {
                // some error?
                Pair<String, Integer> fileLine = FileLineParser.getOutputFileLine(line);
                if (fileLine != null) {
                    outputListener = new FileOutputListener(fileLine.first(), fileLine.second());
                }
                return Collections.singletonList(ConvertedLine.forText(line, outputListener));
            } else {
                // karma log
                for (Browser browser : browsers) {
                    Pair<String, Integer> fileLine = browser.getOutputFileLine(line);
                    if (fileLine != null) {
                        outputListener = new FileOutputListener(fileLine.first(), fileLine.second());
                        break;
                    }
                }
            }
            testRunner.logMessageToTestResultsWindowOutputView(line);
            return Collections.singletonList(ConvertedLine.forText(line, outputListener));
        }

    }

    private static final class FileOutputListener implements OutputListener {

        final String file;
        final int line;


        public FileOutputListener(String file, int line) {
            assert file != null;
            this.file = file;
            this.line = line;
        }

        @Override
        public void outputLineSelected(OutputEvent ev) {
            // noop
        }

        @Override
        public void outputLineAction(OutputEvent ev) {
            RequestProcessor.getDefault().post(new Runnable() {
                @Override
                public void run() {
                    FileUtils.openFile(new File(file), line);
                }
            });
        }

        @Override
        public void outputLineCleared(OutputEvent ev) {
            // noop
        }

    }

    static final class FileLineParser {

        // (/usr/lib/node_modules/karma/node_modules/coffee-script/lib/coffee-script/coffee-script.js:211:36)
        // ^/home/gapon/NetBeansProjects/Calculator-PHPUnit5/README.md:1$
        static final Pattern OUTPUT_FILE_LINE_PATTERN = Pattern.compile("(?:^|\\()(?<FILE>[^(]+?):(?<LINE>\\d+)(?::\\d+)?(?:$|\\))"); // NOI18N


        static Pair<String, Integer> getOutputFileLine(String line) {
            Matcher matcher = OUTPUT_FILE_LINE_PATTERN.matcher(line);
            if (!matcher.find()) {
                return null;
            }
            String file = matcher.group("FILE"); // NOI18N
            if (!new File(file).isFile()) {
                // incomplete path
                return null;
            }
            return Pair.of(file, Integer.valueOf(matcher.group("LINE"))); // NOI18N
        }

    }

}
