/*
 * Shell.java
 *
 * Copyright (C) 2009-12 by RStudio, Inc.
 *
 * Unless you have received this program directly from RStudio pursuant
 * to the terms of a commercial license agreement with RStudio, then
 * this program is licensed to you under the terms of version 3 of the
 * GNU Affero General Public License. This program is distributed WITHOUT
 * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
 * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
 * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
 *
 */

package org.rstudio.studio.client.workbench.views.console.shell;

import com.google.gwt.core.client.GWT;
import com.google.gwt.core.client.JsArrayString;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.core.client.Scheduler.ScheduledCommand;
import com.google.gwt.event.dom.client.*;
import com.google.gwt.user.client.Command;
import com.google.inject.Inject;

import org.rstudio.core.client.BrowseCap;
import org.rstudio.core.client.CommandWithArg;
import org.rstudio.core.client.StringUtil;
import org.rstudio.core.client.command.CommandBinder;
import org.rstudio.core.client.command.Handler;
import org.rstudio.core.client.command.KeyboardHelper;
import org.rstudio.core.client.command.KeyboardShortcut;
import org.rstudio.core.client.jsonrpc.RpcObjectList;
import org.rstudio.studio.client.application.events.EventBus;
import org.rstudio.studio.client.common.CommandLineHistory;
import org.rstudio.studio.client.common.debugging.ErrorManager;
import org.rstudio.studio.client.common.debugging.events.UnhandledErrorEvent;
import org.rstudio.studio.client.common.debugging.model.ErrorHandlerType;
import org.rstudio.studio.client.common.shell.ShellDisplay;
import org.rstudio.studio.client.server.ServerError;
import org.rstudio.studio.client.server.ServerRequestCallback;
import org.rstudio.studio.client.server.Void;
import org.rstudio.studio.client.server.VoidServerRequestCallback;
import org.rstudio.studio.client.workbench.ConsoleEditorProvider;
import org.rstudio.studio.client.workbench.commands.Commands;
import org.rstudio.studio.client.workbench.model.ClientInitState;
import org.rstudio.studio.client.workbench.model.ClientState;
import org.rstudio.studio.client.workbench.model.ConsoleAction;
import org.rstudio.studio.client.workbench.model.Session;
import org.rstudio.studio.client.workbench.model.SessionInfo;
import org.rstudio.studio.client.workbench.model.helper.StringStateValue;
import org.rstudio.studio.client.workbench.prefs.model.UIPrefs;
import org.rstudio.studio.client.workbench.views.console.events.*;
import org.rstudio.studio.client.workbench.views.console.model.ConsoleServerOperations;
import org.rstudio.studio.client.workbench.views.console.shell.assist.CompletionManager;
import org.rstudio.studio.client.workbench.views.console.shell.assist.CompletionPopupPanel;
import org.rstudio.studio.client.workbench.views.console.shell.assist.HistoryCompletionManager;
import org.rstudio.studio.client.workbench.views.console.shell.assist.RCompletionManager;
import org.rstudio.studio.client.workbench.views.console.shell.editor.InputEditorDisplay;
import org.rstudio.studio.client.workbench.views.environment.events.DebugModeChangedEvent;
import org.rstudio.studio.client.workbench.views.source.editors.text.DocDisplay;
import org.rstudio.studio.client.workbench.views.source.editors.text.ace.AceEditorNative;

import java.util.ArrayList;

public class Shell implements ConsoleInputHandler,
                              ConsoleWriteOutputHandler,
                              ConsoleWriteErrorHandler,
                              ConsoleWritePromptHandler,
                              ConsoleWriteInputHandler,
                              ConsolePromptHandler,
                              ConsoleResetHistoryHandler,
                              ConsoleRestartRCompletedEvent.Handler,
                              ConsoleExecutePendingInputEvent.Handler,
                              SendToConsoleHandler,
                              DebugModeChangedEvent.Handler,
                              RunCommandWithDebugEvent.Handler,
                              UnhandledErrorEvent.Handler
{
   static interface Binder extends CommandBinder<Commands, Shell>
   {
   }

   public interface Display extends ShellDisplay
   {
      void onBeforeUnselected();
      void onBeforeSelected();
      void onSelected();
   }
   
   @Inject
   public Shell(ConsoleServerOperations server, 
                EventBus eventBus,
                Display display,
                Session session,
                Commands commands,
                UIPrefs uiPrefs, 
                ErrorManager errorManager,
                ConsoleEditorProvider tracker)
   {
      super() ;

      ((Binder)GWT.create(Binder.class)).bind(commands, this);
      
      server_ = server ;
      eventBus_ = eventBus ;
      view_ = display ;
      commands_ = commands;
      errorManager_ = errorManager;
      input_ = view_.getInputEditorDisplay() ;
      historyManager_ = new CommandLineHistory(input_);
      browseHistoryManager_ = new CommandLineHistory(input_);
      prefs_ = uiPrefs;
      tracker.setConsoleEditor(input_);
      
      prefs_.surroundSelection().bind(new CommandWithArg<String>()
      {
         @Override
         public void execute(String value)
         {
            ((DocDisplay) input_).setSurroundSelectionPref(value);
         }
      });

      inputAnimator_ = new ShellInputAnimator(view_.getInputEditorDisplay());
      
      view_.setMaxOutputLines(session.getSessionInfo().getConsoleActionsLimit());

      keyDownPreviewHandlers_ = new ArrayList<KeyDownPreviewHandler>() ;
      keyPressPreviewHandlers_ = new ArrayList<KeyPressPreviewHandler>() ;

      InputKeyDownHandler handler = new InputKeyDownHandler() ;
      // This needs to be a capturing key down handler or else Ace will have
      // handled the event before we had a chance to prevent it
      view_.addCapturingKeyDownHandler(handler) ;
      view_.addKeyPressHandler(handler) ;
      
      eventBus.addHandler(ConsoleInputEvent.TYPE, this); 
      eventBus.addHandler(ConsoleWriteOutputEvent.TYPE, this);
      eventBus.addHandler(ConsoleWriteErrorEvent.TYPE, this);
      eventBus.addHandler(ConsoleWritePromptEvent.TYPE, this);
      eventBus.addHandler(ConsoleWriteInputEvent.TYPE, this);
      eventBus.addHandler(ConsolePromptEvent.TYPE, this);
      eventBus.addHandler(ConsoleResetHistoryEvent.TYPE, this);
      eventBus.addHandler(ConsoleRestartRCompletedEvent.TYPE, this);
      eventBus.addHandler(ConsoleExecutePendingInputEvent.TYPE, this);
      eventBus.addHandler(SendToConsoleEvent.TYPE, this);
      eventBus.addHandler(DebugModeChangedEvent.TYPE, this);
      eventBus.addHandler(RunCommandWithDebugEvent.TYPE, this);
      eventBus.addHandler(UnhandledErrorEvent.TYPE, this);
      
      final CompletionManager completionManager
                  = new RCompletionManager(view_.getInputEditorDisplay(),
                                          null,
                                          new CompletionPopupPanel(), 
                                          server, 
                                          null,
                                          null,
                                          null,
                                          (DocDisplay) view_.getInputEditorDisplay(),
                                          true);
      addKeyDownPreviewHandler(completionManager) ;
      addKeyPressPreviewHandler(completionManager) ;
      
      addKeyDownPreviewHandler(new HistoryCompletionManager(
            view_.getInputEditorDisplay(), server));

      AceEditorNative.syncUiPrefs(uiPrefs);

      sessionInit(session);
   }
   
   private void sessionInit(Session session)
   {
      SessionInfo sessionInfo = session.getSessionInfo();
      ClientInitState clientState = sessionInfo.getClientState();

      new StringStateValue(GROUP_CONSOLE, STATE_INPUT, ClientState.TEMPORARY, clientState) {
         @Override
         protected void onInit(String value)
         {
            initialInput_ = value;
         }
         @Override
         protected String getValue()
         {
            return view_.getInputEditorDisplay().getText();
         }
      };

      JsArrayString history = sessionInfo.getConsoleHistory();
      if (history != null)
         setHistory(history);

      RpcObjectList<ConsoleAction> actions = sessionInfo.getConsoleActions();
      if (actions != null)
      {
         view_.playbackActions(actions);
      }

      if (sessionInfo.getResumed())
      {
         // no special UI for this (resuming session with all console
         // history and other UI state preserved deemed adequate feedback) 
      }
   }

   public Display getDisplay()
   {
      return view_ ;
   }

   @Handler
   void onConsoleClear()
   {
      // clear output
      view_.clearOutput();
      
      // notify server
      server_.resetConsoleActions(new VoidServerRequestCallback());
      
      // if we don't bounce setFocus the menu retains focus
      Scheduler.get().scheduleDeferred(new ScheduledCommand() {
         public void execute()
         {
            view_.getInputEditorDisplay().setFocus(true);
         }
      });

   }

   public void addKeyDownPreviewHandler(KeyDownPreviewHandler handler)
   {
      keyDownPreviewHandlers_.add(handler) ;
   }
   
   public void addKeyPressPreviewHandler(KeyPressPreviewHandler handler)
   {
      keyPressPreviewHandlers_.add(handler) ;
   }
   
   public void onConsoleInput(final ConsoleInputEvent event)
   {
      server_.consoleInput(event.getInput(), 
                           new ServerRequestCallback<Void>() {
         @Override
         public void onError(ServerError error) 
         {
            // show the error in the console then re-prompt
            view_.consoleWriteError("Error: " + error.getUserMessage() + "\n");
            if (lastPromptText_ != null)
               consolePrompt(lastPromptText_, false);
         }
      });
   }

   public void onConsoleWriteOutput(ConsoleWriteOutputEvent event)
   {
      view_.consoleWriteOutput(event.getOutput()) ;
   }

   public void onConsoleWriteError(final ConsoleWriteErrorEvent event)
   {
      view_.consoleWriteError(event.getError());
   }
   
   public void onUnhandledError(UnhandledErrorEvent event)
   {
      if (!debugging_)
      {
         view_.consoleWriteExtendedError(
               event.getError().getErrorMessage(),
               event.getError(), 
               prefs_.autoExpandErrorTracebacks().getValue(),
               getHistoryEntry(0));
      }
   }
   
   public void onConsoleWriteInput(ConsoleWriteInputEvent event)
   {
      view_.consoleWriteInput(event.getInput());
   }

   public void onConsoleWritePrompt(ConsoleWritePromptEvent event)
   {
      view_.consoleWritePrompt(event.getPrompt());
   }

   public void onConsolePrompt(ConsolePromptEvent event)
   {
      String prompt = event.getPrompt().getPromptText() ;
      boolean addToHistory = event.getPrompt().getAddToHistory() ;
      
      consolePrompt(prompt, addToHistory) ;
   }

   private void consolePrompt(String prompt, boolean addToHistory)
   {
      view_.consolePrompt(prompt, true) ;

      if (lastPromptText_ == null
            && initialInput_ != null
            && initialInput_.length() > 0)
      {
         view_.getInputEditorDisplay().setText(initialInput_);
         view_.ensureInputVisible();
      }

      addToHistory_ = addToHistory;
      resetHistoryPosition();
      lastPromptText_ = prompt ;

      if (restoreFocus_)
      {
         restoreFocus_ = false;
         view_.getInputEditorDisplay().setFocus(true);
      }
   }
   
   public void onConsoleResetHistory(ConsoleResetHistoryEvent event)
   {
      setHistory(event.getHistory());
   }
   
   @Override
   public void onRestartRCompleted(ConsoleRestartRCompletedEvent event)
   {
      if (view_.isPromptEmpty())
         eventBus_.fireEvent(new SendToConsoleEvent("", true));
         
      focus();
   }
   
   private void processCommandEntry()
   {
      String commandText = view_.processCommandEntry() ;
      if (addToHistory_ && (commandText.length() > 0))
         addToHistory(commandText);

      // fire event 
      eventBus_.fireEvent(new ConsoleInputEvent(commandText));
   }

   public void onSendToConsole(final SendToConsoleEvent event)
   {  
      final InputEditorDisplay display = view_.getInputEditorDisplay();
      
      // get anything already at the console
      final String previousInput = StringUtil.notNull(display.getText());
      
      // define code block we execute at finish
      Command finishSendToConsole = new Command() {
         @Override
         public void execute()
         {
            if (event.shouldExecute())
            {
               processCommandEntry();
               if (previousInput.length() > 0)
                  display.setText(previousInput);
            }
            
            if (!event.shouldExecute() || event.shouldFocus())
            {
               display.setFocus(true);
               display.collapseSelection(false);
            }  
         }
      };
      
      // do standrd finish if we aren't animating
      if (!event.shouldAnimate())
      {
         display.clear();
         display.setText(event.getCode()); 
         finishSendToConsole.execute();
      }
      else
      {
         inputAnimator_.enque(event.getCode(), finishSendToConsole);
      }
   }
   
   @Override
   public void onExecutePendingInput(ConsoleExecutePendingInputEvent event)
   {
      // if the source view is delegating a Cmd+Enter to us then
      // take it if we are focused and we have a command to enter
      if (view_.getInputEditorDisplay().isFocused() &&
         (view_.getInputEditorDisplay().getText().length() > 0))
      {
         processCommandEntry();  
      }
      // otherwise delegate back to the source view. we do this via
      // executing a command which is a bit of hack but it's a clean
      // way to call code within the "current editor" (an event would
      // go to all editors). another alternative would be to 
      // call a method on the SourceShim
      else
      {
         commands_.executeCodeWithoutFocus().execute();
      }
   }
   
   @Override
   public void onDebugModeChanged(DebugModeChangedEvent event)
   {
      if (event.debugging())
      {
         view_.ensureInputVisible();
      }
      debugging_ = event.debugging();
   }
   
   @Override
   public void onRunCommandWithDebug(final RunCommandWithDebugEvent event)
   {
      // Invoked from the "Rerun with Debug" command in the ConsoleError widget.
      errorManager_.setDebugSessionHandlerType(
            ErrorHandlerType.ERRORS_BREAK,
            new ServerRequestCallback<Void>()
            {
               @Override
               public void onResponseReceived(Void v)
               {
                  eventBus_.fireEvent(new SendToConsoleEvent(
                        event.getCommand(), true));
               }
               
               @Override
               public void onError(ServerError error)
               {
                  // if we failed to set debug mode, don't rerun the command
               }
            }); 
   }

   private final class InputKeyDownHandler implements KeyDownHandler,
                                                      KeyPressHandler
   {
      public void onKeyDown(KeyDownEvent event)
      {
         int keyCode = event.getNativeKeyCode();

         for (KeyDownPreviewHandler handler : keyDownPreviewHandlers_)
         {
            if (handler.previewKeyDown(event.getNativeEvent()))
            {
               event.preventDefault() ;
               event.stopPropagation() ;
               return;
            }
         }
         
         if (event.getNativeKeyCode() == KeyCodes.KEY_TAB)
            event.preventDefault();

         int modifiers = KeyboardShortcut.getModifierValue(event.getNativeEvent());

         if (event.isUpArrow() && modifiers == 0)
         {
            if ((input_.getCurrentLineNum() == 0) || input_.isCursorAtEnd())
            {
               event.preventDefault();
               event.stopPropagation();

               navigateHistory(-1);
            }
         }
         else if (event.isDownArrow() && modifiers == 0)
         {
            if ((input_.getCurrentLineNum() == input_.getCurrentLineCount() - 1)
                || input_.isCursorAtEnd())
            {
               event.preventDefault();
               event.stopPropagation();

               navigateHistory(1);
            }
         }
         else if (keyCode == KeyCodes.KEY_ENTER && modifiers == 0)
         {
            event.preventDefault();
            event.stopPropagation();

            restoreFocus_ = true;
            processCommandEntry();
         }
         else if (keyCode == KeyCodes.KEY_ESCAPE && modifiers == 0)
         {
            event.preventDefault();

            if (input_.getText().length() == 0)
            {
               // view_.isPromptEmpty() is to check for cases where the
               // server is prompting but not at the top level. Escape
               // needs to send null in those cases.
               // For example, try "scan()" function
               if (view_.isPromptEmpty())
               {
                  // interrupt server
                  server_.interrupt(new VoidServerRequestCallback());
               }
               else
               {
                  // if the input is already empty then send a console reset
                  // which will jump us back to the main prompt
                  eventBus_.fireEvent(new ConsoleInputEvent(null));
               }
            }
             
            input_.clear();
         }
         else
         {
            int mod = KeyboardShortcut.getModifierValue(event.getNativeEvent());
            if (mod == KeyboardShortcut.CTRL)
            {
               switch (keyCode)
               {
                  case 'L':
                     Shell.this.onConsoleClear() ;
                     event.preventDefault() ;
                     break;
               }
            }
            else if (mod == KeyboardShortcut.ALT)
            {
               if (KeyboardHelper.isHyphenKeycode(keyCode))
               {
                  event.preventDefault();
                  event.stopPropagation();
                  input_.replaceSelection(" <- ", true);
               }
            }
            else if (
                  (BrowseCap.hasMetaKey() && 
                   (mod == (KeyboardShortcut.META + KeyboardShortcut.SHIFT))) ||
                  (!BrowseCap.hasMetaKey() && 
                   (mod == (KeyboardShortcut.CTRL + KeyboardShortcut.SHIFT))))
            {
               switch (keyCode)
               {
                  case KeyCodes.KEY_M:
                     event.preventDefault();
                     event.stopPropagation();
                     input_.replaceSelection(" %>% ", true);
                     break;
               }
            }
         }
      }

      public void onKeyPress(KeyPressEvent event)
      {
         for (KeyPressPreviewHandler handler : keyPressPreviewHandlers_)
         {
            if (handler.previewKeyPress(event.getCharCode()))
            {
               event.preventDefault() ;
               event.stopPropagation() ;
               return;
            }
         }
      }

      @SuppressWarnings("unused")
      private boolean lastKeyCodeWasZero_;
   }
   
   private boolean isBrowsePrompt()
   {
      return lastPromptText_ != null && (lastPromptText_.startsWith("Browse"));
   }
   
   private void resetHistoryPosition()
   {
      historyManager_.resetPosition();
      browseHistoryManager_.resetPosition();
   }
   
   private void addToHistory(String commandText)
   {
      if (isBrowsePrompt())
         browseHistoryManager_.addToHistory(commandText);
      else
         historyManager_.addToHistory(commandText);
   }
   
   private String getHistoryEntry(int offset)
   {
      if (isBrowsePrompt())
         return browseHistoryManager_.getHistoryEntry(offset);
      else
         return historyManager_.getHistoryEntry(offset);
   }

   private void navigateHistory(int offset)
   {
      if (isBrowsePrompt())
         browseHistoryManager_.navigateHistory(offset);
      else
         historyManager_.navigateHistory(offset);
      
      view_.ensureInputVisible();
   }

   public void focus()
   {
      input_.setFocus(true);
   }
   
   private void setHistory(JsArrayString history)
   {
      ArrayList<String> historyList = new ArrayList<String>(history.length());
      for (int i = 0; i < history.length(); i++)
         historyList.add(history.get(i));
      historyManager_.setHistory(historyList);
      browseHistoryManager_.resetPosition();
   }

   public void onBeforeUnselected()
   {
      view_.onBeforeUnselected();

   }

   public void onBeforeSelected()
   {
      view_.onBeforeSelected();
   }

   public void onSelected()
   {
      view_.onSelected();
   }

   private final ConsoleServerOperations server_ ;
   private final EventBus eventBus_ ;
   private final Display view_ ;
   private final Commands commands_;
   private final ErrorManager errorManager_;
   private final InputEditorDisplay input_ ;
   private final ArrayList<KeyDownPreviewHandler> keyDownPreviewHandlers_ ;
   private final ArrayList<KeyPressPreviewHandler> keyPressPreviewHandlers_ ;
   // indicates whether the next command should be added to history
   private boolean addToHistory_ ;
   private String lastPromptText_ ;
   private final UIPrefs prefs_;
 
   private final CommandLineHistory historyManager_;
   private final CommandLineHistory browseHistoryManager_;
   
   private final ShellInputAnimator inputAnimator_;

   private String initialInput_ ;

   private static final String GROUP_CONSOLE = "console";
   private static final String STATE_INPUT = "input";

   private boolean restoreFocus_ = true;
   private boolean debugging_ = false;
}
