/*
 * Copyright 2000-2016 JetBrains s.r.o.
 *
 * 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.intellij.ide.actionMacro;

import com.intellij.icons.AllIcons;
import com.intellij.ide.IdeBundle;
import com.intellij.ide.IdeEventQueue;
import com.intellij.openapi.Disposable;
import com.intellij.openapi.actionSystem.*;
import com.intellij.openapi.actionSystem.ex.ActionManagerEx;
import com.intellij.openapi.actionSystem.ex.AnActionListener;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.components.PersistentStateComponent;
import com.intellij.openapi.components.State;
import com.intellij.openapi.components.Storage;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.keymap.KeymapUtil;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.Messages;
import com.intellij.openapi.ui.playback.PlaybackContext;
import com.intellij.openapi.ui.playback.PlaybackRunner;
import com.intellij.openapi.ui.popup.Balloon;
import com.intellij.openapi.ui.popup.JBPopupAdapter;
import com.intellij.openapi.ui.popup.JBPopupFactory;
import com.intellij.openapi.ui.popup.LightweightWindowEvent;
import com.intellij.openapi.util.Disposer;
import com.intellij.openapi.util.registry.Registry;
import com.intellij.openapi.wm.CustomStatusBarWidget;
import com.intellij.openapi.wm.IdeFrame;
import com.intellij.openapi.wm.StatusBar;
import com.intellij.openapi.wm.WindowManager;
import com.intellij.ui.awt.RelativePoint;
import com.intellij.ui.components.panels.NonOpaquePanel;
import com.intellij.util.Consumer;
import com.intellij.util.ui.AnimatedIcon;
import com.intellij.util.ui.BaseButtonBehavior;
import com.intellij.util.ui.PositionTracker;
import com.intellij.util.ui.UIUtil;
import org.jdom.Element;
import org.jetbrains.annotations.NonNls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import javax.swing.*;
import java.awt.*;
import java.awt.event.InputEvent;
import java.awt.event.KeyEvent;
import java.awt.event.MouseEvent;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Set;

@State(
  name = "ActionMacroManager",
  storages = @Storage("macros.xml")
)
public class ActionMacroManager implements PersistentStateComponent<Element>, Disposable {
  private static final Logger LOG = Logger.getInstance("#com.intellij.ide.actionMacro.ActionMacroManager");

  private static final String TYPING_SAMPLE = "WWWWWWWWWWWWWWWWWWWW";
  private static final String RECORDED = "Recorded: ";

  private boolean myIsRecording;
  private final ActionManagerEx myActionManager;
  private ActionMacro myLastMacro;
  private ActionMacro myRecordingMacro;
  private ArrayList<ActionMacro> myMacros = new ArrayList<>();
  private String myLastMacroName = null;
  private boolean myIsPlaying = false;
  @NonNls
  private static final String ELEMENT_MACRO = "macro";
  private final IdeEventQueue.EventDispatcher myKeyProcessor;

  private Set<InputEvent> myLastActionInputEvent = new HashSet<>();
  private ActionMacroManager.Widget myWidget;

  private String myLastTyping = "";

  public ActionMacroManager(ActionManagerEx actionManagerEx) {
    myActionManager = actionManagerEx;
    myActionManager.addAnActionListener(new AnActionListener() {
      @Override
      public void beforeActionPerformed(AnAction action, DataContext dataContext, final AnActionEvent event) {
        String id = myActionManager.getId(action);
        if (id == null) return;
        //noinspection HardCodedStringLiteral
        if ("StartStopMacroRecording".equals(id)) {
          myLastActionInputEvent.add(event.getInputEvent());
        }
        else if (myIsRecording) {
          myRecordingMacro.appendAction(id);
          String shortcut = null;
          if (event.getInputEvent() instanceof KeyEvent) {
            shortcut = KeymapUtil.getKeystrokeText(KeyStroke.getKeyStrokeForEvent((KeyEvent)event.getInputEvent()));
          }
          notifyUser(id + (shortcut != null ? " (" + shortcut + ")" : ""), false);
          myLastActionInputEvent.add(event.getInputEvent());
        }
      }

      @Override
      public void beforeEditorTyping(char c, DataContext dataContext) {
      }

      @Override
      public void afterActionPerformed(final AnAction action, final DataContext dataContext, final AnActionEvent event) {
      }
    });

    myKeyProcessor = new MyKeyPostpocessor();
    IdeEventQueue.getInstance().addPostprocessor(myKeyProcessor, null);
  }

  @Override
  public void loadState(Element state) {
    myMacros = new ArrayList<>();
    for (Element macroElement : state.getChildren(ELEMENT_MACRO)) {
      ActionMacro macro = new ActionMacro();
      macro.readExternal(macroElement);
      myMacros.add(macro);
    }

    registerActions();
  }

  @Nullable
   @Override
   public Element getState() {
    Element element = new Element("state");
    for (ActionMacro macro : myMacros) {
      Element macroElement = new Element(ELEMENT_MACRO);
      macro.writeExternal(macroElement);
      element.addContent(macroElement);
    }
    return element;
  }

  public static ActionMacroManager getInstance() {
    return ApplicationManager.getApplication().getComponent(ActionMacroManager.class);
  }

  public void startRecording(String macroName) {
    LOG.assertTrue(!myIsRecording);
    myIsRecording = true;
    myRecordingMacro = new ActionMacro(macroName);

    final StatusBar statusBar = WindowManager.getInstance().getIdeFrame(null).getStatusBar();
    myWidget = new Widget(statusBar);
    statusBar.addWidget(myWidget);
  }


  private class Widget implements CustomStatusBarWidget, Consumer<MouseEvent> {

    private AnimatedIcon myIcon = new AnimatedIcon("Macro recording",
                                                   new Icon[]{
                                                     AllIcons.Ide.Macro.Recording_1,
                                                     AllIcons.Ide.Macro.Recording_2,
                                                     AllIcons.Ide.Macro.Recording_3,
                                                     AllIcons.Ide.Macro.Recording_4},
                                                   AllIcons.Ide.Macro.Recording_1, 1000);
    private StatusBar myStatusBar;
    private final WidgetPresentation myPresentation;

    private JPanel myBalloonComponent;
    private Balloon myBalloon;
    private final JLabel myText;

    private Widget(StatusBar statusBar) {
      myStatusBar = statusBar;
      myPresentation = new WidgetPresentation() {
        @Override
        public String getTooltipText() {
          return "Macro is being recorded now";
        }

        @Override
        public Consumer<MouseEvent> getClickConsumer() {
          return Widget.this;
        }
      };


      new BaseButtonBehavior(myIcon) {
        @Override
        protected void execute(MouseEvent e) {
          showBalloon();
        }
      };

      myBalloonComponent = new NonOpaquePanel(new BorderLayout());

      final AnAction stopAction = ActionManager.getInstance().getAction("StartStopMacroRecording");
      final DefaultActionGroup group = new DefaultActionGroup();
      group.add(stopAction);
      final ActionToolbar tb = ActionManager.getInstance().createActionToolbar(ActionPlaces.STATUS_BAR_PLACE, group, true);
      tb.setMiniMode(true);

      final NonOpaquePanel top = new NonOpaquePanel(new BorderLayout());
      top.add(tb.getComponent(), BorderLayout.WEST);
      myText = new JLabel(RECORDED + "..." + TYPING_SAMPLE, SwingConstants.LEFT);
      final Dimension preferredSize = myText.getPreferredSize();
      myText.setPreferredSize(preferredSize);
      myText.setText("Macro recording started...");
      myLastTyping = "";
      top.add(myText, BorderLayout.CENTER);
      myBalloonComponent.add(top, BorderLayout.CENTER);
    }

    private void showBalloon() {
      if (myBalloon != null) {
        Disposer.dispose(myBalloon);
        return;
      }

      myBalloon = JBPopupFactory.getInstance().createBalloonBuilder(myBalloonComponent)
        .setAnimationCycle(200)
        .setCloseButtonEnabled(true)
        .setHideOnAction(false)
        .setHideOnClickOutside(false)
        .setHideOnFrameResize(false)
        .setHideOnKeyOutside(false)
        .setSmallVariant(true)
        .setShadow(true)
        .createBalloon();

      Disposer.register(myBalloon, new Disposable() {
        @Override
        public void dispose() {
          myBalloon = null;
        }
      });

      myBalloon.addListener(new JBPopupAdapter() {
        @Override
        public void onClosed(LightweightWindowEvent event) {
          if (myBalloon != null) {
            Disposer.dispose(myBalloon);
          }
        }
      });

      myBalloon.show(new PositionTracker<Balloon>(myIcon) {
        @Override
        public RelativePoint recalculateLocation(Balloon object) {
          return new RelativePoint(myIcon, new Point(myIcon.getSize().width / 2, 4));
        }
      }, Balloon.Position.above);
    }

    @Override
    public JComponent getComponent() {
      return myIcon;
    }

    @NotNull
    @Override
    public String ID() {
      return "MacroRecording";
    }

    @Override
    public void consume(MouseEvent mouseEvent) {
    }

    @Override
    public WidgetPresentation getPresentation(@NotNull PlatformType type) {
      return myPresentation;
    }

    @Override
    public void install(@NotNull StatusBar statusBar) {
      showBalloon();
    }

    @Override
    public void dispose() {
      myIcon.dispose();
      if (myBalloon != null) {
        Disposer.dispose(myBalloon);
      }
    }

    public void delete() {
      if (myBalloon != null) {
        Disposer.dispose(myBalloon);
      }
      myStatusBar.removeWidget(ID());
    }

    public void notifyUser(String text) {
      myText.setText(text);
      myText.revalidate();
      myText.repaint();
    }
  }

  public void stopRecording(@Nullable Project project) {
    LOG.assertTrue(myIsRecording);

    if (myWidget != null) {
      myWidget.delete();
      myWidget = null;
    }

    myIsRecording = false;
    myLastActionInputEvent.clear();
    String macroName;
    do {
      macroName = Messages.showInputDialog(project,
                                           IdeBundle.message("prompt.enter.macro.name"),
                                           IdeBundle.message("title.enter.macro.name"),
                                           Messages.getQuestionIcon());
      if (macroName == null) {
        myRecordingMacro = null;
        return;
      }

      if (macroName.isEmpty()) macroName = null;
    }
    while (macroName != null && !checkCanCreateMacro(macroName));

    myLastMacro = myRecordingMacro;
    addRecordedMacroWithName(macroName);
    registerActions();
  }

  private void addRecordedMacroWithName(@Nullable String macroName) {
    if (macroName != null) {
      myRecordingMacro.setName(macroName);
      myMacros.add(myRecordingMacro);
      myRecordingMacro = null;
    }
    else {
      for (int i = 0; i < myMacros.size(); i++) {
        ActionMacro macro = myMacros.get(i);
        if (IdeBundle.message("macro.noname").equals(macro.getName())) {
          myMacros.set(i, myRecordingMacro);
          myRecordingMacro = null;
          break;
        }
      }
      if (myRecordingMacro != null) {
        myMacros.add(myRecordingMacro);
        myRecordingMacro = null;
      }
    }
  }

  public void playbackLastMacro() {
    if (myLastMacro != null) {
      playbackMacro(myLastMacro);
    }
  }

  private void playbackMacro(ActionMacro macro) {
    final IdeFrame frame = WindowManager.getInstance().getIdeFrame(null);
    assert frame != null;

    StringBuffer script = new StringBuffer();
    ActionMacro.ActionDescriptor[] actions = macro.getActions();
    for (ActionMacro.ActionDescriptor each : actions) {
      each.generateTo(script);
    }

    final PlaybackRunner runner = new PlaybackRunner(script.toString(), new PlaybackRunner.StatusCallback.Edt() {

      @Override
      public void messageEdt(PlaybackContext context, String text, Type type) {
        if (type == Type.message || type == Type.error) {
          StatusBar statusBar = frame.getStatusBar();
          if (statusBar != null) {
            if (context != null) {
              text = "Line " + context.getCurrentLine() + ": " + text;
            }
            statusBar.setInfo(text);
          }
        }
      }
    }, Registry.is("actionSystem.playback.useDirectActionCall"), true, Registry.is("actionSystem.playback.useTypingTargets"));

    myIsPlaying = true;

    runner.run()
      .doWhenDone(() -> {
        StatusBar statusBar = frame.getStatusBar();
        statusBar.setInfo("Script execution finished");
      })
      .doWhenProcessed(() -> myIsPlaying = false);
  }

  public boolean isRecording() {
    return myIsRecording;
  }

  @Override
  public void dispose() {
    IdeEventQueue.getInstance().removePostprocessor(myKeyProcessor);
  }

  public ActionMacro[] getAllMacros() {
    return myMacros.toArray(new ActionMacro[myMacros.size()]);
  }

  public void removeAllMacros() {
    if (myLastMacro != null) {
      myLastMacroName = myLastMacro.getName();
      myLastMacro = null;
    }
    myMacros = new ArrayList<>();
  }

  public void addMacro(ActionMacro macro) {
    myMacros.add(macro);
    if (myLastMacroName != null && myLastMacroName.equals(macro.getName())) {
      myLastMacro = macro;
      myLastMacroName = null;
    }
  }

  public void playMacro(ActionMacro macro) {
    playbackMacro(macro);
    myLastMacro = macro;
  }

  public boolean hasRecentMacro() {
    return myLastMacro != null;
  }

  public void registerActions() {
    unregisterActions();
    HashSet<String> registeredIds = new HashSet<>(); // to prevent exception if 2 or more targets have the same name

    ActionMacro[] macros = getAllMacros();
    for (final ActionMacro macro : macros) {
      String actionId = macro.getActionId();

      if (!registeredIds.contains(actionId)) {
        registeredIds.add(actionId);
        myActionManager.registerAction(actionId, new InvokeMacroAction(macro));
      }
    }
  }

  public void unregisterActions() {

    // unregister Tool actions
    String[] oldIds = myActionManager.getActionIds(ActionMacro.MACRO_ACTION_PREFIX);
    for (final String oldId : oldIds) {
      myActionManager.unregisterAction(oldId);
    }
  }

  public boolean checkCanCreateMacro(String name) {
    final ActionManagerEx actionManager = (ActionManagerEx)ActionManager.getInstance();
    final String actionId = ActionMacro.MACRO_ACTION_PREFIX + name;
    if (actionManager.getAction(actionId) != null) {
      if (Messages.showYesNoDialog(IdeBundle.message("message.macro.exists", name),
                                   IdeBundle.message("title.macro.name.already.used"),
                                   Messages.getWarningIcon()) != Messages.YES) {
        return false;
      }
      actionManager.unregisterAction(actionId);
      removeMacro(name);
    }

    return true;
  }

  private void removeMacro(String name) {
    for (int i = 0; i < myMacros.size(); i++) {
      ActionMacro macro = myMacros.get(i);
      if (name.equals(macro.getName())) {
        myMacros.remove(i);
        break;
      }
    }
  }

  public boolean isPlaying() {
    return myIsPlaying;
  }

  private static class InvokeMacroAction extends AnAction {
    private final ActionMacro myMacro;

    InvokeMacroAction(ActionMacro macro) {
      myMacro = macro;
      getTemplatePresentation().setText(macro.getName(), false);
    }

    @Override
    public void actionPerformed(AnActionEvent e) {
      IdeEventQueue.getInstance().doWhenReady(() -> getInstance().playMacro(myMacro));
    }

    @Override
    public void update(AnActionEvent e) {
      super.update(e);
      e.getPresentation().setEnabled(!getInstance().isPlaying());
    }
  }

  private class MyKeyPostpocessor implements IdeEventQueue.EventDispatcher {

    @Override
    public boolean dispatch(@NotNull AWTEvent e) {
      if (isRecording() && e instanceof KeyEvent) {
        postProcessKeyEvent((KeyEvent)e);
      }
      return false;
    }

    public void postProcessKeyEvent(KeyEvent e) {
      if (e.getID() != KeyEvent.KEY_PRESSED) return;
      if (myLastActionInputEvent.contains(e)) {
        myLastActionInputEvent.remove(e);
        return;
      }
      final boolean modifierKeyIsPressed = e.getKeyCode() == KeyEvent.VK_CONTROL ||
                                           e.getKeyCode() == KeyEvent.VK_ALT ||
                                           e.getKeyCode() == KeyEvent.VK_META ||
                                           e.getKeyCode() == KeyEvent.VK_SHIFT;
      if (modifierKeyIsPressed) return;

      final boolean ready = IdeEventQueue.getInstance().getKeyEventDispatcher().isReady();
      final boolean isChar = e.getKeyChar() != KeyEvent.CHAR_UNDEFINED && UIUtil.isReallyTypedEvent(e);
      final boolean hasActionModifiers = e.isAltDown() | e.isControlDown() | e.isMetaDown();
      final boolean plainType = isChar && !hasActionModifiers;
      final boolean isEnter = e.getKeyCode() == KeyEvent.VK_ENTER;

      if (plainType && ready && !isEnter) {
        myRecordingMacro.appendKeytyped(e.getKeyChar(), e.getKeyCode(), e.getModifiers());
        notifyUser(Character.valueOf(e.getKeyChar()).toString(), true);
      }
      else if ((!plainType && ready) || isEnter) {
        final String stroke = KeyStroke.getKeyStrokeForEvent(e).toString();

        final int pressed = stroke.indexOf("pressed");
        String key = stroke.substring(pressed + "pressed".length());
        String modifiers = stroke.substring(0, pressed);

        String shortcut = (modifiers.replaceAll("ctrl", "control").trim() + " " + key.trim()).trim();

        myRecordingMacro.appendShortcut(shortcut);
        notifyUser(KeymapUtil.getKeystrokeText(KeyStroke.getKeyStrokeForEvent(e)), false);
      }
    }
  }

  private void notifyUser(String text, boolean typing) {
    String actualText = text;
    if (typing) {
      int maxLength = TYPING_SAMPLE.length();
      myLastTyping += text;
      if (myLastTyping.length() > maxLength) {
        myLastTyping = "..." + myLastTyping.substring(myLastTyping.length() - maxLength);
      }
      actualText = myLastTyping;
    } else {
      myLastTyping = "";
    }

    if (myWidget != null) {
      myWidget.notifyUser(RECORDED + actualText);
    }
  }
}
