/*
 * Copyright (C) 2013 The Android Open Source Project
 *
 * Licensed 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 com.android.tools.idea.gradle.invoker;

import com.android.builder.model.AndroidProject;
import com.android.ide.common.blame.Message;
import com.android.ide.common.blame.SourceFile;
import com.android.ide.common.blame.SourceFilePosition;
import com.android.ide.common.blame.parser.PatternAwareOutputParser;
import com.android.tools.idea.gradle.IdeaGradleProject;
import com.android.tools.idea.gradle.compiler.AndroidGradleBuildConfiguration;
import com.android.tools.idea.gradle.facet.AndroidGradleFacet;
import com.android.tools.idea.gradle.invoker.console.view.GradleConsoleToolWindowFactory;
import com.android.tools.idea.gradle.invoker.console.view.GradleConsoleView;
import com.android.tools.idea.gradle.invoker.messages.GradleBuildTreeViewPanel;
import com.android.tools.idea.gradle.output.parser.BuildOutputParser;
import com.android.tools.idea.gradle.service.notification.errors.AbstractSyncErrorHandler;
import com.android.tools.idea.gradle.util.AndroidGradleSettings;
import com.android.tools.idea.sdk.IdeSdks;
import com.android.tools.idea.sdk.SelectSdkDialog;
import com.google.common.base.Stopwatch;
import com.google.common.collect.Lists;
import com.intellij.compiler.CompilerManagerImpl;
import com.intellij.compiler.CompilerWorkspaceConfiguration;
import com.intellij.execution.ui.ConsoleViewContentType;
import com.intellij.ide.errorTreeView.NewErrorTreeViewPanel;
import com.intellij.notification.Notification;
import com.intellij.notification.NotificationGroup;
import com.intellij.notification.NotificationType;
import com.intellij.openapi.application.Application;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.compiler.CompilerBundle;
import com.intellij.openapi.compiler.CompilerManager;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.externalSystem.model.ExternalSystemException;
import com.intellij.openapi.externalSystem.model.task.ExternalSystemTaskId;
import com.intellij.openapi.externalSystem.model.task.ExternalSystemTaskNotificationListener;
import com.intellij.openapi.externalSystem.model.task.ExternalSystemTaskNotificationListenerAdapter;
import com.intellij.openapi.externalSystem.service.notification.NotificationCategory;
import com.intellij.openapi.externalSystem.service.notification.NotificationData;
import com.intellij.openapi.externalSystem.service.notification.NotificationSource;
import com.intellij.openapi.module.Module;
import com.intellij.openapi.progress.EmptyProgressIndicator;
import com.intellij.openapi.progress.ProgressIndicator;
import com.intellij.openapi.progress.Task;
import com.intellij.openapi.progress.util.AbstractProgressIndicatorExBase;
import com.intellij.openapi.project.DumbModeAction;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.project.ProjectManager;
import com.intellij.openapi.project.ProjectManagerListener;
import com.intellij.openapi.ui.MessageType;
import com.intellij.openapi.ui.Messages;
import com.intellij.openapi.util.Disposer;
import com.intellij.openapi.util.Key;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.openapi.wm.ToolWindow;
import com.intellij.openapi.wm.ToolWindowId;
import com.intellij.openapi.wm.ToolWindowManager;
import com.intellij.openapi.wm.ex.ProgressIndicatorEx;
import com.intellij.pom.Navigatable;
import com.intellij.ui.AppIcon;
import com.intellij.ui.content.*;
import com.intellij.util.Consumer;
import com.intellij.util.Function;
import com.intellij.util.SystemProperties;
import com.intellij.util.ui.MessageCategory;
import org.gradle.tooling.*;
import org.jetbrains.annotations.NonNls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.jps.service.JpsServiceManager;
import org.jetbrains.plugins.gradle.service.project.GradleExecutionHelper;
import org.jetbrains.plugins.gradle.settings.GradleExecutionSettings;

import javax.swing.*;
import javax.swing.event.HyperlinkEvent;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.PrintStream;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Semaphore;

import static com.android.tools.idea.gradle.util.GradleBuilds.CONFIGURE_ON_DEMAND_OPTION;
import static com.android.tools.idea.gradle.util.GradleBuilds.PARALLEL_BUILD_OPTION;
import static com.android.tools.idea.gradle.util.GradleUtil.*;
import static com.android.tools.idea.gradle.util.Projects.getBaseDirPath;
import static com.android.tools.idea.startup.AndroidStudioSpecificInitializer.isAndroidStudio;
import static com.google.common.base.Splitter.on;
import static com.google.common.base.Strings.nullToEmpty;
import static com.google.common.io.Closeables.close;
import static com.intellij.execution.ui.ConsoleViewContentType.ERROR_OUTPUT;
import static com.intellij.execution.ui.ConsoleViewContentType.NORMAL_OUTPUT;
import static com.intellij.openapi.application.ModalityState.NON_MODAL;
import static com.intellij.openapi.ui.MessageType.*;
import static com.intellij.openapi.util.text.StringUtil.*;
import static com.intellij.openapi.vfs.VfsUtil.findFileByIoFile;
import static com.intellij.ui.AppUIUtil.invokeLaterIfProjectAlive;
import static com.intellij.util.ArrayUtil.toStringArray;
import static com.intellij.util.ExceptionUtil.getRootCause;
import static com.intellij.util.ui.UIUtil.invokeLaterIfNeeded;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static org.jetbrains.android.AndroidPlugin.*;
import static org.jetbrains.plugins.gradle.service.project.GradleExecutionHelper.prepare;

/**
 * Invokes Gradle tasks as a IDEA task in the background.
 */
class GradleTasksExecutor extends Task.Backgroundable {
  private static final ExternalSystemTaskNotificationListener GRADLE_LISTENER = new ExternalSystemTaskNotificationListenerAdapter() {
  };

  private static final long ONE_MINUTE_MS = 60L /*sec*/ * 1000L /*millisec*/;
  private static final Logger LOG = Logger.getInstance(GradleInvoker.class);
  public static final NotificationGroup LOGGING_NOTIFICATION = NotificationGroup.logOnlyGroup("Gradle Build (Logging)");
  public static final NotificationGroup BALLOON_NOTIFICATION = NotificationGroup.balloonGroup("Gradle Build (Balloon)");

  // Dummy objects used for mapping {@link AbstractSyncErrorHandler} to the 'build messages' environment.
  private static final Notification DUMMY_NOTIFICATION = new Notification("dummy", "dummy", "dummy", NotificationType.ERROR);
  private static final Object DUMMY_EVENT_SOURCE = new Object();

  @NonNls private static final String CONTENT_NAME = "Gradle Build";
  @NonNls private static final String APP_ICON_ID = "compiler";

  private static final Key<Key<?>> CONTENT_ID_KEY = Key.create("CONTENT_ID");
  private static final int BUFFER_SIZE = 2048;

  private static final String GRADLE_RUNNING_MSG_TITLE = "Gradle Running";

  @NotNull private final Key<Key<?>> myContentId = Key.create("compile_content");

  @NotNull private final Object myMessageViewLock = new Object();
  @NotNull private final Object myCompletionLock  = new Object();
  private int myCompletionCounter;

  @NotNull private final GradleTaskExecutionContext myContext;
  @Nullable private GradleBuildTreeViewPanel myErrorTreeView;

  @NotNull private final GradleExecutionHelper myHelper = new GradleExecutionHelper();

  private volatile int myErrorCount;
  private volatile int myWarningCount;

  @NotNull private volatile ProgressIndicator myIndicator = new EmptyProgressIndicator();

  private volatile boolean myMessageViewIsPrepared;
  private volatile boolean myMessagesAutoActivated;

  private CloseListener myCloseListener;

  GradleTasksExecutor(@NotNull GradleTaskExecutionContext context) {
    super(context.getProject(), "Gradle Build Running", true);
    myContext = context;
  }

  @Override
  public String getProcessId() {
    return "GradleTaskInvocation";
  }

  @Override
  @NotNull
  public DumbModeAction getDumbModeAction() {
    return DumbModeAction.WAIT;
  }

  @Override
  @Nullable
  public NotificationInfo getNotificationInfo() {
    return new NotificationInfo(myErrorCount > 0 ? "Gradle Invocation (errors)" : "Gradle Invocation (success)",
                                "Gradle Invocation Finished", myErrorCount + " Errors, " + myWarningCount + " Warnings", true);
  }

  @Override
  public void run(@NotNull ProgressIndicator indicator) {
    if (isAndroidStudio()) {
      // See https://code.google.com/p/android/issues/detail?id=169743
      clearStoredGradleJvmArgs(getNotNullProject());
    }

    myIndicator = indicator;

    ProjectManager projectManager = ProjectManager.getInstance();
    Project project = getNotNullProject();
    myCloseListener = new CloseListener();
    projectManager.addProjectManagerListener(project, myCloseListener);

    Semaphore semaphore = ((CompilerManagerImpl)CompilerManager.getInstance(project)).getCompilationSemaphore();
    boolean acquired = false;
    try {
      try {
        while (!acquired) {
          acquired = semaphore.tryAcquire(300, MILLISECONDS);
          if (indicator.isCanceled()) {
            // Give up obtaining the semaphore, let compile work begin in order to stop gracefully on cancel event.
            break;
          }
        }
      }
      catch (InterruptedException e) {
        Thread.currentThread().interrupt();
      }

      if (!isHeadless()) {
        addIndicatorDelegate();
      }
      invokeGradleTasks();
    }
    finally {
      try {
        indicator.stop();
        projectManager.removeProjectManagerListener(project, myCloseListener);
      }
      finally {
        if (acquired) {
          semaphore.release();
        }
      }
    }
  }

  private void addIndicatorDelegate() {
    if (myIndicator instanceof ProgressIndicatorEx) {
      ProgressIndicatorEx indicator = (ProgressIndicatorEx)myIndicator;
      indicator.addStateDelegate(new ProgressIndicatorStateDelegate());
    }
  }

  private void closeView() {
    ApplicationManager.getApplication().invokeLater(new Runnable() {
      @Override
      public void run() {
        synchronized (myMessageViewLock) {
          if (myErrorTreeView != null && !getNotNullProject().isDisposed()) {
            addStatisticsMessage(CompilerBundle.message("statistics.error.count", myErrorCount));
            addStatisticsMessage(CompilerBundle.message("statistics.warnings.count", myWarningCount));

            addMessage(new Message(Message.Kind.INFO, "See complete output in console", SourceFilePosition.UNKNOWN), new OpenGradleConsole());
            myErrorTreeView.selectFirstMessage();
          }
        }
      }

      private void addStatisticsMessage(@NotNull String text) {
        addMessage(new Message(Message.Kind.STATISTICS, text, SourceFilePosition.UNKNOWN), null);
      }
    }, NON_MODAL);
  }

  private void invokeGradleTasks() {
    final Project project = getNotNullProject();
    final GradleExecutionSettings executionSettings = getGradleExecutionSettings(project);

    Function<ProjectConnection, Void> executeTasksFunction = new Function<ProjectConnection, Void>() {
      @Override
      public Void fun(ProjectConnection connection) {
        final Stopwatch stopwatch = Stopwatch.createStarted();

        GradleConsoleView consoleView = GradleConsoleView.getInstance(project);
        consoleView.clear();

        addMessage(new Message(Message.Kind.INFO, "Gradle tasks " + myContext.getGradleTasks(), SourceFilePosition.UNKNOWN), null);

        String executingTasksText = "Executing tasks: " + myContext.getGradleTasks();
        consoleView.print(executingTasksText + SystemProperties.getLineSeparator() + SystemProperties.getLineSeparator(), NORMAL_OUTPUT);
        addToEventLog(executingTasksText, INFO);

        GradleOutputForwarder output = new GradleOutputForwarder(consoleView);

        BuildException buildError = null;
        final ExternalSystemTaskId id = myContext.getTaskId();
        CancellationTokenSource cancellationTokenSource = GradleConnector.newCancellationTokenSource();
        try {
          AndroidGradleBuildConfiguration buildConfiguration = AndroidGradleBuildConfiguration.getInstance(project);
          List<String> commandLineArgs = Lists.newArrayList(buildConfiguration.getCommandLineOptions());

          if (buildConfiguration.USE_CONFIGURATION_ON_DEMAND && !commandLineArgs.contains(CONFIGURE_ON_DEMAND_OPTION)) {
            commandLineArgs.add(CONFIGURE_ON_DEMAND_OPTION);
          }

          if (!commandLineArgs.contains(PARALLEL_BUILD_OPTION) &&
              CompilerWorkspaceConfiguration.getInstance(project).PARALLEL_COMPILATION) {
            commandLineArgs.add(PARALLEL_BUILD_OPTION);
          }

          commandLineArgs.add(AndroidGradleSettings.createProjectProperty(AndroidProject.PROPERTY_INVOKED_FROM_IDE, true));
          commandLineArgs.addAll(myContext.getCommandLineArgs());
          addLocalMavenRepoInitScriptCommandLineOption(commandLineArgs);
          attemptToUseEmbeddedGradle(project);

          LOG.info("Build command line options: " + commandLineArgs);

          List<String> jvmArgs = Collections.emptyList();
          BuildLauncher launcher = connection.newBuild();
          prepare(launcher, id, executionSettings, GRADLE_LISTENER, jvmArgs, commandLineArgs, connection);

          File javaHome = IdeSdks.getJdkPath();
          if (javaHome != null) {
            launcher.setJavaHome(javaHome);
          }

          myContext.storeCancellationInfoFor(id, cancellationTokenSource);
          launcher.forTasks(toStringArray(myContext.getGradleTasks()));
          launcher.withCancellationToken(cancellationTokenSource.token());

          GradleOutputForwarder.Listener outputListener = null;
          if (myContext.getTaskNotificationListener() != null) {
            outputListener = new GradleOutputForwarder.Listener() {
              @Override
              public void onOutput(@NotNull ConsoleViewContentType contentType, @NotNull byte[] data, int offset, int length) {
                if (myContext.isActive(id)) {
                  myContext.getTaskNotificationListener().onTaskOutput(id, new String(data, offset, length), contentType != ERROR_OUTPUT);
                }
              }
            };
          }
          output.attachTo(launcher, outputListener);
          launcher.run();
        }
        catch (BuildException e) {
          buildError = e;
        }
        catch (Throwable e) {
          handleTaskExecutionError(e);
        }
        finally {
          myContext.dropCancellationInfoFor(id);
          String gradleOutput = output.toString();
          Application application = ApplicationManager.getApplication();
          if (isGuiTestingMode()) {
            String testOutput = application.getUserData(GRADLE_BUILD_OUTPUT_IN_GUI_TEST_KEY);
            if (isNotEmpty(testOutput)) {
              gradleOutput = testOutput;
              application.putUserData(GRADLE_BUILD_OUTPUT_IN_GUI_TEST_KEY, null);
            }
          }
          List<Message> buildMessages = Lists.newArrayList(showMessages(gradleOutput));
          if (myErrorCount == 0 && buildError != null && !hasCause(buildError, BuildCancelledException.class)) {
            // Gradle throws BuildCancelledException when we cancel task execution. We don't want to force showing 'Messages' tool
            // window for that situation though.
            showBuildException(buildError, output.getStdErr(), buildMessages);
          }
          output.close();

          stopwatch.stop();

          application.invokeLater(new Runnable() {
            @Override
            public void run() {
              notifyGradleInvocationCompleted(stopwatch.elapsed(MILLISECONDS));
            }
          });

          if (buildError == null || !hasCause(buildError, BuildCancelledException.class)) {
            // Gradle throws BuildCancelledException when we cancel task execution. We don't want to force showing 'Messages' tool
            // window for that situation though.
            application.invokeLater(new Runnable() {
              @Override
              public void run() {
                showMessages();
              }
            });
          }

          boolean buildSuccessful = buildError == null;
          GradleInvocationResult result = new GradleInvocationResult(myContext.getGradleTasks(), buildMessages, buildSuccessful);
          for (GradleInvoker.AfterGradleInvocationTask task : myContext.getGradleInvoker().getAfterInvocationTasks()) {
            task.execute(result);
          }
        }
        return null;
      }
    };

    if (isGuiTestingMode()) {
      // We use this task in GUI tests to simulate errors coming from Gradle project sync.
      Application application = ApplicationManager.getApplication();
      Runnable task = application.getUserData(EXECUTE_BEFORE_PROJECT_BUILD_IN_GUI_TEST_KEY);
      if (task != null) {
        application.putUserData(EXECUTE_BEFORE_PROJECT_BUILD_IN_GUI_TEST_KEY, null);
        task.run();
      }
    }

    File projectDirPath = getBaseDirPath(project);
    myHelper.execute(projectDirPath.getPath(), executionSettings, executeTasksFunction);
  }

  private void handleTaskExecutionError(@NotNull Throwable e) {
    if (myIndicator.isCanceled()) {
      LOG.info("Failed to complete Gradle execution. Project may be closing or already closed.", e);
      return;
    }
    //noinspection ThrowableResultOfMethodCallIgnored
    Throwable rootCause = getRootCause(e);
    final String error = nullToEmpty(rootCause.getMessage());
    if (error.contains("Build cancelled")) {
      return;
    }
    Runnable showErrorTask = new Runnable() {
      @Override
      public void run() {
        String msg = "Failed to complete Gradle execution.";
        if (isEmpty(error)) {
          // Unlikely that 'error' is null or empty, since now we catch the real exception.
          msg += " Cause: unknown.";
        }
        else {
          msg += "\n\nCause:\n" + error;
        }
        addMessage(new Message(Message.Kind.ERROR, msg, SourceFilePosition.UNKNOWN), null);
        showMessages();

        // This is temporary. Once we have support for hyperlinks in "Messages" window, we'll show the error message the with a
        // hyperlink to set the JDK home.
        // For now we show the "Select SDK" dialog, but only giving the option to set the JDK path.
        if (isAndroidStudio() && error.startsWith("Supplied javaHome is not a valid folder")) {
          File androidHome = IdeSdks.getAndroidSdkPath();
          String androidSdkPath = androidHome != null ? androidHome.getPath() : null;
          SelectSdkDialog selectSdkDialog = new SelectSdkDialog(null, androidSdkPath);
          selectSdkDialog.setModal(true);
          if (selectSdkDialog.showAndGet()) {
            final String jdkHome = selectSdkDialog.getJdkHome();
            invokeLaterIfNeeded(new Runnable() {
              @Override
              public void run() {
                ApplicationManager.getApplication().runWriteAction(new Runnable() {
                  @Override
                  public void run() {
                    IdeSdks.setJdkPath(new File(jdkHome));
                  }
                });
              }
            });
          }
        }
      }
    };
    invokeLaterIfProjectAlive(getNotNullProject(), showErrorTask);
  }

  @NotNull
  private List<Message> showMessages(@NotNull String gradleOutput) {
    Iterable<PatternAwareOutputParser> parsers = JpsServiceManager.getInstance().getExtensions(PatternAwareOutputParser.class);
    List<Message> compilerMessages = new BuildOutputParser(parsers).parseGradleOutput(gradleOutput);
    for (Message msg : compilerMessages) {
      addMessage(msg, null);
    }
    return compilerMessages;
  }

  /**
   * Something went wrong while invoking Gradle but the output parsers did not create any build messages. We show the stack trace in the
   * "Messages" view.
   */
  private void showBuildException(@NotNull BuildException e, @NotNull String stdErr, @NotNull List<Message> buildMessages) {
    // There are no error messages to present. Show some feedback indicating that something went wrong.
    if (!stdErr.trim().isEmpty()) {
      // Show the contents of stderr as a compiler error.
      Message msg = new Message(Message.Kind.ERROR, stdErr, SourceFilePosition.UNKNOWN);
      buildMessages.add(msg);
      addMessage(msg, null);
    }
    else {
      // Since we have nothing else to show, just print the stack trace of the caught exception.
      ByteArrayOutputStream out = new ByteArrayOutputStream(BUFFER_SIZE);
      try {
        //noinspection IOResourceOpenedButNotSafelyClosed
        e.printStackTrace(new PrintStream(out));
        String message = "Internal error:" + SystemProperties.getLineSeparator() + out.toString();
        Message msg = new Message(Message.Kind.ERROR, message, SourceFilePosition.UNKNOWN);
        buildMessages.add(msg);
        addMessage(msg, null);
      }
      finally {
        try {
          close(out, true /* swallowIOException */);
        } catch (IOException ex) {
          // Cannot happen
        }
      }
    }
  }

  private void addMessage(@NotNull final Message message, @Nullable final Navigatable navigatable) {
    prepareMessageView();
    switch (message.getKind()) {
      case WARNING:
        myWarningCount++;
        break;
      case ERROR:
        myErrorCount++;
      default:
        // do nothing.
    }
    Runnable addMessageTask = new Runnable() {
      @Override
      public void run() {
        openMessageView();
        add(message, navigatable);
      }
    };
    invokeLaterIfNeeded(addMessageTask);
  }

  private void prepareMessageView() {
    if (!myIndicator.isRunning() || myMessageViewIsPrepared) {
      return;
    }
    myMessageViewIsPrepared = true;
    ApplicationManager.getApplication().invokeLater(new Runnable() {
      @Override
      public void run() {
        if (!getNotNullProject().isDisposed()) {
          synchronized (myMessageViewLock) {
            // Clear messages from the previous compilation
            if (myErrorTreeView == null) {
              // If message view != null, the contents has already been cleared.
              removeUnpinnedBuildMessages(getNotNullProject(), null);
            }
          }
        }
      }
    });
  }

  static void clearMessageView(@NotNull final Project project) {
    ApplicationManager.getApplication().invokeLater(new Runnable() {
      @Override
      public void run() {
        if (!project.isDisposed()) {
          removeUnpinnedBuildMessages(project, null);
        }
      }
    });
  }

  private static void removeUnpinnedBuildMessages(@NotNull final Project project, @Nullable final Content toKeep) {
    if (project.isInitialized()) {
      MessageView messageView = MessageView.SERVICE.getInstance(project);
      Content[] contents = messageView.getContentManager().getContents();
      for (Content content : contents) {
        if (content.isPinned() || content == toKeep) {
          continue;
        }
        if (content.getUserData(CONTENT_ID_KEY) != null) { // the content was added by me
          messageView.getContentManager().removeContent(content, true);
        }
      }
    }
  }

  private void openMessageView() {
    if (myIndicator.isCanceled()) {
      return;
    }

    Project project = getNotNullProject();
    JComponent component;
    synchronized (myMessageViewLock) {
      if (myErrorTreeView != null) {
        return;
      }
      //noinspection ConstantConditions
      myErrorTreeView = new GradleBuildTreeViewPanel(project);
      myErrorTreeView.setProcessController(new NewErrorTreeViewPanel.ProcessController() {
        @Override
        public void stopProcess() {
          stopBuild();
        }

        @Override
        public boolean isProcessStopped() {
          return !myIndicator.isRunning();
        }
      });
      component = myErrorTreeView.getComponent();
    }

    Content content = ContentFactory.SERVICE.getInstance().createContent(component, CONTENT_NAME, true);
    content.putUserData(CONTENT_ID_KEY, myContentId);

    MessageView messageView = getMessageView();
    ContentManager contentManager = messageView.getContentManager();
    contentManager.addContent(content);

    myCloseListener.setContent(contentManager, content);

    removeUnpinnedBuildMessages(getNotNullProject(), content);
    contentManager.setSelectedContent(content);
  }

  private void activateGradleConsole() {
    ToolWindow window = getToolWindowManager().getToolWindow(GradleConsoleToolWindowFactory.ID);
    if (window != null) {
      window.activate(null, false);
    }
  }

  private void add(@NotNull Message message, @Nullable Navigatable navigatable) {
    synchronized (myMessageViewLock) {
      if (myErrorTreeView != null && !getNotNullProject().isDisposed()) {
        Message.Kind messageKind = message.getKind();
        int type = translateMessageKind(messageKind);
        LinkAwareMessageData messageData = prepareMessage(message);
        if (navigatable == null) {
          VirtualFile file = findFileFrom(message);
          myErrorTreeView.addMessage(type,
                                     messageData.textLines,
                                     file,
                                     message.getLineNumber() - 1,
                                     message.getColumn() - 1,
                                     messageData.hyperlinkListener);
        }
        else {
          myErrorTreeView.addMessage(type, messageData.textLines, null, navigatable, null, null, messageData.hyperlinkListener);
        }

        boolean autoActivate = !myMessagesAutoActivated && type == MessageCategory.ERROR;
        if (autoActivate) {
          myMessagesAutoActivated = true;
          activateMessageView();
        }
      }
    }
  }

  @NotNull
  private LinkAwareMessageData prepareMessage(@NotNull Message message) {
    final List<String> rawTextLines;
    String text = message.getText();
    if (text.indexOf('\n') == -1) {
      rawTextLines = Collections.singletonList(text);
    }
    else {
      rawTextLines = Lists.newArrayList(on('\n').split(text));
    }
    if (message.getKind() != Message.Kind.ERROR) {
      //noinspection unchecked
      return new LinkAwareMessageData(toStringArray(rawTextLines), null);
    }

    // The general idea is to adapt existing gradle output enhancers (AbstractSyncErrorHandler) to the 'gradle build' process.
    // Their are built in assumption that they enhance external system's NotificationData by custom html hyperlinks markup and
    // corresponding listeners. So, what we do here is just providing fake NotificationData to the handlers and extract the
    // data added by them (if any).
    List<String> enhancedTextLines = null; // Text lines with added hyperlinks, i.e. hold text to actually show to end-user
    List<String> linesBuffer = Lists.newArrayListWithExpectedSize(1);
    linesBuffer.add("");
    final NotificationData dummyData =
      new NotificationData("", message.getText(), NotificationCategory.ERROR, NotificationSource.PROJECT_SYNC);
    String previousMessage = dummyData.getMessage();
    for (AbstractSyncErrorHandler handler : AbstractSyncErrorHandler.EP_NAME.getExtensions()) {
      // We experienced that AbstractSyncErrorHandler often look to the first line only (because gradle output is sequential, line-by-line.
      // That's why we roll through all message lines and offer every of them to the handler.
      for (int i = 0; i < rawTextLines.size(); i++) {
        String line = rawTextLines.get(i);

        // This logic comes from gradle itself. Corresponding 'clearing' code remains at BuildFailureParser.parse()
        String prefixToStrip = "> ";
        if (line.startsWith(prefixToStrip)) {
          line = line.substring(prefixToStrip.length());
        }

        linesBuffer.set(0, line);
        boolean handled = handler.handleError(linesBuffer, new ExternalSystemException(message.getText()), dummyData, getNotNullProject());
        if (handled) {
          // Extract text added by the handler and store it at the 'enhancedTextLines' collection.
          String currentMessage = dummyData.getMessage();
          if (currentMessage.length() > previousMessage.length()) {
            int j = previousMessage.length();
            if (currentMessage.charAt(j) == '\n') {
              j++;
            }
            String addedText = currentMessage.substring(j);
            if (enhancedTextLines == null) {
              enhancedTextLines = Lists.newArrayList(rawTextLines);
            }
            enhancedTextLines.add(addedText);
            previousMessage = currentMessage;
          }
        }
      }
    }
    final List<String> textLinesToUse;
    final Consumer<String> hyperlinkListener;
    if (enhancedTextLines == null) {
      textLinesToUse = rawTextLines;
      hyperlinkListener = null;
    }
    else {
      textLinesToUse = enhancedTextLines;
      // AbstractSyncErrorHandler add hyperlinks (which we derived earlier and stored in 'enhancedTextLines') and hyperlink listeners
      // (facaded by the NotificationData.getListener()). So, what we do here is just delegating 'activate link' event
      // to those hyperlink listeners added by AbstractSyncErrorHandler.
      hyperlinkListener = new Consumer<String>() {
        @Override
        public void consume(String url) {
          HyperlinkEvent event = new HyperlinkEvent(DUMMY_EVENT_SOURCE, HyperlinkEvent.EventType.ACTIVATED, null, url);
          dummyData.getListener().hyperlinkUpdate(DUMMY_NOTIFICATION, event);
        }
      };
    }
    return new LinkAwareMessageData(toStringArray(textLinesToUse), hyperlinkListener);
  }

  @Nullable
  private VirtualFile findFileFrom(@NotNull Message message) {
    SourceFile source = message.getSourceFilePositions().get(0).getFile();
    if (source.getSourceFile() != null) {
      return findFileByIoFile(source.getSourceFile(), true);
    }
    if (source.getDescription() != null) {
      String gradlePath = source.getDescription();
      Module module = findModuleByGradlePath(getNotNullProject(), gradlePath);
      if (module != null) {
        AndroidGradleFacet facet = AndroidGradleFacet.getInstance(module);
        // if we got here facet is not null;
        assert facet != null;
        IdeaGradleProject gradleProject = facet.getGradleProject();
        return gradleProject != null ? gradleProject.getBuildFile() : null;
      }
    }
    return null;
  }

  private static int translateMessageKind(@NotNull Message.Kind kind) {
    switch (kind) {
      case INFO:
        return MessageCategory.INFORMATION;
      case WARNING:
        return MessageCategory.WARNING;
      case ERROR:
        return MessageCategory.ERROR;
      case STATISTICS:
        return MessageCategory.STATISTICS;
      case SIMPLE:
        return MessageCategory.SIMPLE;
      default:
        LOG.info("Unknown message kind: " + kind);
        return 0;
    }
  }

  private void notifyGradleInvocationCompleted(long durationMillis) {
    Project project = getNotNullProject();
    if (!project.isDisposed()) {
      String statusMsg = createStatusMessage(durationMillis);
      MessageType messageType = myErrorCount > 0 ? ERROR : myWarningCount > 0 ? WARNING : INFO;
      if (durationMillis > ONE_MINUTE_MS) {
        BALLOON_NOTIFICATION.createNotification(statusMsg, messageType).notify(project);
      }
      else {
        addToEventLog(statusMsg, messageType);
      }
    }
  }

  @NotNull
  private String createStatusMessage(long durationMillis) {
    String message = "Gradle build finished";
    if (myErrorCount > 0) {
      if (myWarningCount > 0) {
        message += String.format(" with %d error(s) and %d warning(s)", myErrorCount, myWarningCount);
      }
      else {
        message += String.format(" with %d error(s)", myErrorCount);
      }
    }
    else if (myWarningCount > 0) {
      message += String.format(" with %d warnings(s)", myWarningCount);
    }
    message = message + " in " + formatDuration(durationMillis);
    return message;
  }

  private void addToEventLog(@NotNull String message, @NotNull MessageType type) {
    LOGGING_NOTIFICATION.createNotification(message, type).notify(myProject);
  }

  @NotNull
  private ToolWindowManager getToolWindowManager() {
    return ToolWindowManager.getInstance(getNotNullProject());
  }

  private void showMessages() {
    synchronized (myMessageViewLock) {
      if (myErrorTreeView != null && !getNotNullProject().isDisposed()) {
        MessageView messageView = getMessageView();
        Content[] contents = messageView.getContentManager().getContents();
        for (Content content : contents) {
          if (content.getUserData(CONTENT_ID_KEY) != null) {
            messageView.getContentManager().setSelectedContent(content);
            return;
          }
        }
      }
    }
  }

  @NotNull
  private MessageView getMessageView() {
    return MessageView.SERVICE.getInstance(getNotNullProject());
  }

  @NotNull
  private Project getNotNullProject() {
    assert myProject != null;
    return myProject;
  }

  private void activateMessageView() {
    synchronized (myMessageViewLock) {
      if (myErrorTreeView != null) {
        ToolWindow window = getToolWindowManager().getToolWindow(ToolWindowId.MESSAGES_WINDOW);
        if (window != null) {
          window.activate(null, false);
        }
      }
    }
  }

  private void cancel() {
    if (!myIndicator.isCanceled()) {
      stopBuild();
      myIndicator.cancel();
    }
  }

  private void stopBuild() {
    ExternalSystemTaskId taskId = myContext.getTaskId();
    if (myIndicator.isRunning()) {
      myIndicator.setText("Stopping Gradle build...");
    }
    GradleInvoker.getInstance(getNotNullProject()).cancelTask(taskId);
  }

  /**
   * Regular {@link #queue()} method might return immediately if current task is executed in a separate non-calling thread.
   * <p/>
   * However, sometimes we want to wait for the task completion, e.g. consider a use-case when we execute an IDE run configuration.
   * It opens dedicated run/debug tool window and displays execution output there. However, it is shown as finished as soon as
   * control flow returns. That's why we don't want to return control flow until the actual task completion.
   * <p/>
   * This method allows to achieve that target - it executes gradle tasks under the IDE 'progress management system' (shows progress
   * bar at the bottom) in a separate thread and doesn't return control flow to the calling thread until all target tasks are actually
   * executed.
   */
  public void queueAndWaitForCompletion() {
    final int counterBefore;
    synchronized (myCompletionLock) {
      counterBefore = myCompletionCounter;
    }
    invokeLaterIfNeeded(new Runnable() {
      @Override
      public void run() {
        queue();
      }
    });
    synchronized (myCompletionLock) {
      while (true) {
        if (myCompletionCounter > counterBefore) {
          break;
        }
        try {
          myCompletionLock.wait();
        }
        catch (InterruptedException e) {
          // Just stop waiting.
          break;
        }
      }
    }
  }

  @Override
  public void onSuccess() {
    super.onSuccess();
    onCompletion();
  }

  @Override
  public void onCancel() {
    super.onCancel();
    onCompletion();
  }

  private void onCompletion() {
    synchronized (myCompletionLock) {
      myCompletionCounter++;
      myCompletionLock.notifyAll();
    }
  }

  private class CloseListener extends ContentManagerAdapter implements ProjectManagerListener {
    private ContentManager myContentManager;
    @Nullable private Content myContent;

    private boolean myIsApplicationExitingOrProjectClosing;
    private boolean myUserAcceptedCancel;

    @Override
    public void projectOpened(Project project) {
    }

    @Override
    public boolean canCloseProject(Project project) {
      if (!project.equals(myProject)) {
        return true;
      }
      if (shouldPromptUser()) {
        myUserAcceptedCancel = askUserToCancelGradleExecution();
        if (!myUserAcceptedCancel) {
          return false; // veto closing
        }
        cancel();
        return true;
      }
      return !myIndicator.isRunning();
    }

    @Override
    public void projectClosed(Project project) {
      if (project.equals(myProject) && myContent != null) {
        myContentManager.removeContent(myContent, true);
      }
    }

    @Override
    public void projectClosing(Project project) {
      if (project.equals(myProject)) {
        myIsApplicationExitingOrProjectClosing = true;
      }
    }

    void setContent(@NotNull ContentManager contentManager, @Nullable Content content) {
      myContent = content;
      myContentManager = contentManager;
      contentManager.addContentManagerListener(this);
    }

    @Override
    public void contentRemoved(ContentManagerEvent event) {
      if (event.getContent() == myContent) {
        synchronized (myMessageViewLock) {
          Project project = getNotNullProject();
          if (myErrorTreeView != null && !project.isDisposed()) {
            Disposer.dispose(myErrorTreeView);
            myErrorTreeView = null;
            if (myIndicator.isRunning()) {
              cancel();
            }
            AppIcon appIcon = AppIcon.getInstance();
            if (appIcon.hideProgress(project, APP_ICON_ID)) {
              //noinspection ConstantConditions
              appIcon.setErrorBadge(project, null);
            }
          }
        }
        myContentManager.removeContentManagerListener(this);
        if (myContent != null) {
          myContent.release();
        }
        myContent = null;
      }
    }

    @Override
    public void contentRemoveQuery(ContentManagerEvent event) {
      if (event.getContent() == myContent && !myIndicator.isCanceled() && shouldPromptUser()) {
        myUserAcceptedCancel = askUserToCancelGradleExecution();
        if (!myUserAcceptedCancel) {
          event.consume(); // veto closing
        }
      }
    }

    private boolean shouldPromptUser() {
      return !myUserAcceptedCancel && !myIsApplicationExitingOrProjectClosing && myIndicator.isRunning();
    }

    private boolean askUserToCancelGradleExecution() {
      String msg = "Gradle is running. Proceed with Project closing?";
      int result = Messages.showYesNoDialog(myProject, msg, GRADLE_RUNNING_MSG_TITLE, Messages.getQuestionIcon());
      return result == Messages.YES;
    }
  }

  private class ProgressIndicatorStateDelegate extends AbstractProgressIndicatorExBase {
    @Override
    public void cancel() {
      super.cancel();
      closeView();
      stopAppIconProgress();
    }

    @Override
    public void stop() {
      super.stop();
      if (!isCanceled()) {
        closeView();
      }
      stopAppIconProgress();
    }

    private void stopAppIconProgress() {
      invokeLaterIfNeeded(new Runnable() {
        @Override
        public void run() {
          AppIcon appIcon = AppIcon.getInstance();
          Project project = getNotNullProject();
          if (appIcon.hideProgress(project, APP_ICON_ID)) {
            if (myErrorCount > 0) {
              appIcon.setErrorBadge(project, String.valueOf(myErrorCount));
              appIcon.requestAttention(project, true);
            }
            else {
              appIcon.setOkBadge(project, true);
              appIcon.requestAttention(project, false);
            }
          }
        }
      });
    }

    @Override
    protected void onProgressChange() {
      prepareMessageView();
    }
  }

  private class OpenGradleConsole implements Navigatable {
    @Override
    public void navigate(boolean requestFocus) {
      activateGradleConsole();
    }

    @Override
    public boolean canNavigate() {
      return true;
    }

    @Override
    public boolean canNavigateToSource() {
      return false;
    }
  }

  /**
   * 'Parameter object' pattern for preparing data to be stored at the 'build messages' output tree structure.
   */
  private static class LinkAwareMessageData {
    /** Target node text split by lines. */
    @NotNull final String[] textLines;
    /** A listener to use for the target text's hyperlinks (if any). Is expected to receives link's href value as an argument. */
    @Nullable final Consumer<String> hyperlinkListener;

    LinkAwareMessageData(@NotNull String[] textLines, @Nullable Consumer<String> hyperlinkListener) {
      this.textLines = textLines;
      this.hyperlinkListener = hyperlinkListener;
    }
  }
}
