/*
 * RmdOutput.java
 *
 * Copyright (C) 2009-14 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.rmarkdown;

import java.util.Map;
import java.util.HashMap;

import org.rstudio.core.client.command.CommandBinder;
import org.rstudio.core.client.dom.WindowEx;
import org.rstudio.core.client.files.FileSystemItem;
import org.rstudio.core.client.widget.Operation;
import org.rstudio.core.client.widget.OperationWithInput;
import org.rstudio.core.client.widget.ProgressIndicator;
import org.rstudio.core.client.widget.ProgressOperation;
import org.rstudio.studio.client.RStudioGinjector;
import org.rstudio.studio.client.application.Desktop;
import org.rstudio.studio.client.application.events.EventBus;
import org.rstudio.studio.client.application.events.RestartStatusEvent;
import org.rstudio.studio.client.common.GlobalDisplay;
import org.rstudio.studio.client.common.SimpleRequestCallback;
import org.rstudio.studio.client.common.filetypes.FileTypeRegistry;
import org.rstudio.studio.client.common.viewfile.ViewFilePanel;
import org.rstudio.studio.client.pdfviewer.PDFViewer;
import org.rstudio.studio.client.rmarkdown.events.ConvertToShinyDocEvent;
import org.rstudio.studio.client.rmarkdown.events.RenderRmdEvent;
import org.rstudio.studio.client.rmarkdown.events.RenderRmdSourceEvent;
import org.rstudio.studio.client.rmarkdown.events.RmdRenderCompletedEvent;
import org.rstudio.studio.client.rmarkdown.events.RmdRenderStartedEvent;
import org.rstudio.studio.client.rmarkdown.events.RmdShinyDocStartedEvent;
import org.rstudio.studio.client.rmarkdown.model.RMarkdownServerOperations;
import org.rstudio.studio.client.rmarkdown.model.RmdOutputFormat;
import org.rstudio.studio.client.rmarkdown.model.RmdPreviewParams;
import org.rstudio.studio.client.rmarkdown.model.RmdRenderResult;
import org.rstudio.studio.client.rmarkdown.model.RmdShinyDocInfo;
import org.rstudio.studio.client.rmarkdown.ui.RmdOutputFrame;
import org.rstudio.studio.client.rmarkdown.ui.ShinyDocumentWarningDialog;
import org.rstudio.studio.client.rsconnect.ui.RSConnectPublishButton;
import org.rstudio.studio.client.server.ServerError;
import org.rstudio.studio.client.server.ServerRequestCallback;
import org.rstudio.studio.client.server.VoidServerRequestCallback;
import org.rstudio.studio.client.server.Void;
import org.rstudio.studio.client.workbench.commands.Commands;
import org.rstudio.studio.client.workbench.prefs.events.UiPrefsChangedEvent;
import org.rstudio.studio.client.workbench.prefs.events.UiPrefsChangedHandler;
import org.rstudio.studio.client.workbench.prefs.model.UIPrefs;

import com.google.gwt.core.client.JavaScriptObject;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.core.client.Scheduler.ScheduledCommand;
import com.google.gwt.event.logical.shared.ValueChangeEvent;
import com.google.gwt.event.logical.shared.ValueChangeHandler;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;

@Singleton
public class RmdOutput implements RmdRenderStartedEvent.Handler,
                                  RmdRenderCompletedEvent.Handler,
                                  RmdShinyDocStartedEvent.Handler,
                                  RenderRmdEvent.Handler,
                                  RenderRmdSourceEvent.Handler,
                                  RestartStatusEvent.Handler,
                                  UiPrefsChangedHandler
{
   public interface Binder
   extends CommandBinder<Commands, RmdOutput> {}
   
   @Inject
   public RmdOutput(EventBus eventBus, 
                    Commands commands,
                    GlobalDisplay globalDisplay,
                    FileTypeRegistry fileTypeRegistry,
                    Provider<ViewFilePanel> pViewFilePanel,
                    Binder binder,
                    UIPrefs prefs,
                    PDFViewer pdfViewer,
                    RMarkdownServerOperations server)
   {
      globalDisplay_ = globalDisplay;
      fileTypeRegistry_ = fileTypeRegistry;
      pViewFilePanel_ = pViewFilePanel;
      prefs_ = prefs;
      pdfViewer_ = pdfViewer;
      server_ = server;
      events_ = eventBus;
      
      eventBus.addHandler(RmdRenderStartedEvent.TYPE, this);
      eventBus.addHandler(RmdRenderCompletedEvent.TYPE, this);
      eventBus.addHandler(RmdShinyDocStartedEvent.TYPE, this);
      eventBus.addHandler(RenderRmdEvent.TYPE, this);
      eventBus.addHandler(RenderRmdSourceEvent.TYPE, this);
      eventBus.addHandler(RestartStatusEvent.TYPE, this);
      eventBus.addHandler(UiPrefsChangedEvent.TYPE, this);

      prefs_.rmdViewerType().addValueChangeHandler(new ValueChangeHandler<Integer>()
      {
         @Override
         public void onValueChange(ValueChangeEvent<Integer> e)
         {
            onViewerTypeChanged(e.getValue());
         }
      });

      binder.bind(commands, this);
      
      exportRmdOutputClosedCallback();
   }
   
   @Override
   public void onRmdRenderStarted(RmdRenderStartedEvent event)
   {
      // When a Word document starts rendering, tell the desktop frame 
      // (if it exists) to get ready; this generally involves closing the 
      // document in preparation for a refresh
      if (event.getFormat().getFormatName()
            .equals(RmdOutputFormat.OUTPUT_WORD_DOCUMENT) &&
          Desktop.isDesktop())
      {
         Desktop.getFrame().prepareShowWordDoc();
      }
   }
   
   @Override
   public void onRmdRenderCompleted(RmdRenderCompletedEvent event)
   {
      // if there's a custom operation to be run when render completes, run
      // that instead
      if (onRenderCompleted_ != null)
      {
         onRenderCompleted_.execute();
         onRenderCompleted_ = null;
         return;
      }

      // ignore failures and completed Shiny docs (the latter are handled when
      // the server starts rather than when the render process is finished)
      final RmdRenderResult result = event.getResult();
      if (result.isShinyDocument())
      {
         shinyDoc_ = null;
         return;
      }
      
      if (result.hasShinyContent() && !result.isShinyDocument())
      {
         // If the result has Shiny content but wasn't rendered as a Shiny
         // document, suggest rendering as a Shiny document instead
         new ShinyDocumentWarningDialog(new OperationWithInput<Integer>()
         {
            @Override
            public void execute(Integer input)
            {
               switch (input)
               {
               case ShinyDocumentWarningDialog.RENDER_SHINY_NO:
                  if (result.getSucceeded())
                     displayRenderResult(result);
                  break;
               case ShinyDocumentWarningDialog.RENDER_SHINY_ONCE:
                  rerenderAsShiny(result);
                  break;
               case ShinyDocumentWarningDialog.RENDER_SHINY_ALWAYS:
                  events_.fireEvent(new ConvertToShinyDocEvent
                        (result.getTargetFile()));
                  break;
               }
            }
         }).showModal();
      }
      else if (result.getSucceeded())
      {
         displayRenderResult(event.getResult());
      }
   }
      
   @Override
   public void onRmdShinyDocStarted(RmdShinyDocStartedEvent event)
   {
      shinyDoc_ = event.getDocInfo();
      RmdRenderResult result = 
            RmdRenderResult.createFromShinyDoc(shinyDoc_);
      displayHTMLRenderResult(result);
   }
   
   @Override
   public void onRenderRmd(final RenderRmdEvent event)
   {
      final Operation renderOperation = new Operation() {
         @Override
         public void execute()
         {
            server_.renderRmd(event.getSourceFile(), 
                              event.getSourceLine(),
                              event.getFormat(),
                              event.getEncoding(), 
                              event.getParamsFile(),
                              event.asTempfile(),
                              event.asShiny(),
                  new SimpleRequestCallback<Boolean>());
         }
      };

      // If there's a running shiny document for this file and it's not a 
      // presentation, we can do an in-place reload. Note that we don't
      // currently support in-place reload for Shiny presentations since we
      // would need to hook a client event at the end of the re-render that
      // emitted updated slide navigation information and then plumbed that
      // information back into the preview window.
      if (shinyDoc_ != null &&
          event.getSourceFile().equals(shinyDoc_.getFile()) &&
          !shinyDoc_.getFormat().getFormatName().endsWith(
                RmdOutputFormat.OUTPUT_PRESENTATION_SUFFIX))
      {
         final RmdRenderResult result = 
               RmdRenderResult.createFromShinyDoc(shinyDoc_);
         displayHTMLRenderResult(result);
      }
      else
      {
         performRenderOperation(renderOperation);
      }
   }
   
   @Override
   public void onRenderRmdSource(final RenderRmdSourceEvent event)
   {
      performRenderOperation(new Operation() {
         @Override
         public void execute()
         {
            server_.renderRmdSource(event.getSource(),
                                    new SimpleRequestCallback<Boolean>()); 
         }
      });
   }

   @Override
   public void onRestartStatus(RestartStatusEvent event)
   {
      // preemptively close the satellite window when R restarts (so we don't
      // wait around if the session doesn't get a chance to tell us about
      // terminated renders)
      if (event.getStatus() == RestartStatusEvent.RESTART_INITIATED) 
      {
         if (outputFrame_ != null)
            outputFrame_.closeOutputFrame(false);
         restarting_ = true;
      }
      else
      {
         restarting_ =  false;
      }
   }

   @Override
   public void onUiPrefsChanged(UiPrefsChangedEvent e)
   {
      onViewerTypeChanged(prefs_.rmdViewerType().getValue());
   }
   
   // Private methods ---------------------------------------------------------
   
   private void onViewerTypeChanged(int newViewerType)
   {
      if (outputFrame_ != null && 
          outputFrame_.getWindowObject() != null && 
          newViewerType != outputFrame_.getViewerType())
      {
         // close the existing frame
         RmdPreviewParams params = outputFrame_.getPreviewParams();
         outputFrame_.closeOutputFrame(true);
         
         // reset the scroll position (as it will vary with the document width,
         // which will change)
         params.setScrollPosition(0);
         
         // open a new one with the same parameters
         outputFrame_ = createOutputFrame(newViewerType);
         outputFrame_.showRmdPreview(params);
      }
      else if (outputFrame_ != null && 
               outputFrame_.getWindowObject() == null &&
               outputFrame_.getViewerType() != newViewerType)
      {
         // output frame exists but doesn't have a loaded doc, clear it so we'll
         // create the frame appropriate to this type on next render
         outputFrame_ = null;
      }
   }
   
   // perform the given render after terminating the currently running Shiny
   // application if there is one
   private void performRenderOperation(final Operation renderOperation)
   {
      if (shinyDoc_ != null)
      {
         // there is a Shiny doc running; we'll need to terminate it before 
         // we can render this document
         outputFrame_.closeOutputFrame(false);
         server_.terminateRenderRmd(true, new ServerRequestCallback<Void>()
         {
            @Override
            public void onResponseReceived(Void v)
            {
               onRenderCompleted_ = renderOperation;
               shinyDoc_ = null;
            }

            @Override
            public void onError(ServerError error)
            {
               globalDisplay_.showErrorMessage("Shiny Terminate Failed", 
                     "The Shiny document " + shinyDoc_.getFile() + " needs to " +
                     "be stopped before the document can be rendered.");
            }
         });
      }
      else
      {
         renderOperation.execute();
      }
   }
   
   private void rerenderAsShiny(RmdRenderResult result)
   {
      events_.fireEvent(new RenderRmdEvent(
            result.getTargetFile(), result.getTargetLine(), 
            null, result.getTargetEncoding(), null, false, true));
   }
   
   private void displayRenderResult(final RmdRenderResult result)
   {
      // don't display anything if user doesn't want to
      if (prefs_.rmdViewerType().getValue() == RMD_VIEWER_TYPE_NONE)
         return;
      
      String extension = FileSystemItem.getExtensionFromPath(
                                                result.getOutputFile()); 
      if (".pdf".equals(extension))
      {
         String previewer = prefs_.pdfPreview().getValue();
         if (previewer.equals(UIPrefs.PDF_PREVIEW_RSTUDIO))
         {
            pdfViewer_.viewPdfUrl(
                  result.getOutputUrl(), 
                  result.getPreviewSlide() >= 0 ? 
                        result.getPreviewSlide() : null);
         }
         else if (!previewer.equals(UIPrefs.PDF_PREVIEW_NONE))
         {
            if (Desktop.isDesktop())
               Desktop.getFrame().showPDF(result.getOutputFile(),
                                          result.getPreviewSlide());
            else 
               globalDisplay_.showHtmlFile(result.getOutputFile());
         }
      }
      else if (".docx".equals(extension) || 
               ".rtf".equals(extension) ||
               ".odt".equals(extension))
      {
         if (Desktop.isDesktop())
            globalDisplay_.showWordDoc(result.getOutputFile());
         
         // it's not possible to show Word docs inline in a useful way from
         // within the browser, so just offer to download the file.
         else
         {
            globalDisplay_.showYesNoMessage(GlobalDisplay.MSG_INFO, 
                  "R Markdown Render Completed", 
                  "R Markdown has finished rendering " + 
                  result.getTargetFile() + " to " + 
                  result.getOutputFile() + ".", 
                  false, 
                  new ProgressOperation()
                  {
                     @Override
                     public void execute(ProgressIndicator indicator)
                     {
                        globalDisplay_.showWordDoc(result.getOutputFile());
                        indicator.onCompleted();
                     }
                  },
                  null, 
                  "Download File", 
                  "OK", 
                  false);
         }
      }
      else if (".html".equals(extension))
      {
         displayHTMLRenderResult(result);
      }
      else if (".md".equalsIgnoreCase(extension) || 
               extension.toLowerCase().startsWith(".markdown") ||
               ".tex".equalsIgnoreCase(extension))
      {
         ViewFilePanel viewFilePanel = pViewFilePanel_.get();
         viewFilePanel.showFile(
            FileSystemItem.createFile(result.getOutputFile()), "UTF-8");
      }
      else
      {
         if (Desktop.isDesktop())
            Desktop.getFrame().showFile(result.getOutputFile());
         else
            globalDisplay_.openWindow(result.getOutputUrl());
      }
   }
   
   private void displayHTMLRenderResult(RmdRenderResult result)
   {
      // find the last known position for this file
      int scrollPosition = 0;
      String anchor = "";
      if (scrollPositions_.containsKey(keyFromResult(result)))
      {
         scrollPosition = scrollPositions_.get(keyFromResult(result));
      }
      if (anchors_.containsKey(keyFromResult(result)))
      {
         anchor = anchors_.get(keyFromResult(result));
      }
      final RmdPreviewParams params = RmdPreviewParams.create(
            result, scrollPosition, anchor);
      
      // don't host presentations in the viewer pane--ioslides doesn't scale
      // slides well without help
      final int newViewerType = result.isHtmlPresentation() && 
            prefs_.rmdViewerType().getValue() == RMD_VIEWER_TYPE_PANE ?
                    RMD_VIEWER_TYPE_WINDOW : 
                    prefs_.rmdViewerType().getValue();
      
      // if we're about to pop open a window but one of the publish buttons
      // is waiting for a render to complete, skip the preview entirely so 
      // we don't disturb the publish flow with a window popping up
      if (newViewerType == RMD_VIEWER_TYPE_WINDOW &&
            RSConnectPublishButton.isAnyRmdRenderPending())
      {
         return;
      }
      
      // get the window object if available
      WindowEx win = null;
      boolean needsReopen = false;
      if (outputFrame_ != null)
      {
         win = outputFrame_.getWindowObject();
         if (outputFrame_.getViewerType() != newViewerType)
            needsReopen = true;
      }
      
      // if there's a window up but it's showing a different document type, 
      // close it so that we can create a new one better suited to this doc type
      if (needsReopen || 
            (win != null && 
             result_ != null && 
             !result_.getFormatName().equals(result.getFormatName())))
      {
         outputFrame_.closeOutputFrame(false);
         outputFrame_ = null;
         win = null;
         // let window finish closing before continuing
         Scheduler.get().scheduleDeferred(new ScheduledCommand()
         {
            @Override
            public void execute()
            {
               displayRenderResult(null, newViewerType, params);
            }
         });
      }
      else
      {
         displayRenderResult(win, newViewerType, params);
      }
   }
   
   private void displayRenderResult(WindowEx win, int viewerType, 
                                    RmdPreviewParams params)
   {
      if (viewerType == RMD_VIEWER_TYPE_NONE)
         return;
      
      RmdRenderResult result = params.getResult();
      
      if (outputFrame_ == null)
         outputFrame_ = createOutputFrame(viewerType);
      
      // we're refreshing if the window is up and we're pulling the same
      // output file as the last one
      boolean isRefresh = win != null &&
                          result_ != null && 
                          result_.getOutputFile().equals(
                                result.getOutputFile());

      // if this isn't a refresh but there's a window up, cache the scroll
      // position of the old document before we replace it
      if (!isRefresh && result_ != null && win != null)
      {
         cacheDocPosition(result_, outputFrame_.getScrollPosition(), 
                          outputFrame_.getAnchor());
      }

      // if it is a refresh, use the doc's existing positions
      if (isRefresh)
      {
         params.setScrollPosition(outputFrame_.getScrollPosition());
         params.setAnchor(outputFrame_.getAnchor());
      }

      outputFrame_.showRmdPreview(params);

      // save the result so we know if the next render is a re-render of the
      // same document
      result_ = result;
   }
 
   private final native void exportRmdOutputClosedCallback()/*-{
      var registry = this;     
      $wnd.notifyRmdOutputClosed = $entry(
         function(params) {
            registry.@org.rstudio.studio.client.rmarkdown.RmdOutput::notifyRmdOutputClosed(Lcom/google/gwt/core/client/JavaScriptObject;)(params);
         }
      ); 
   }-*/;
   
   // when the window is closed, remember our position within it
   private void notifyRmdOutputClosed(JavaScriptObject closeParams)
   {
      // save anchor location for presentations and scroll position for 
      // documents
      RmdPreviewParams params = closeParams.cast();
      cacheDocPosition(params.getResult(), params.getScrollPosition(), 
                       params.getAnchor());
      
      // if this is a Shiny document, stop the associated process
      if (params.isShinyDocument() && !restarting_)
      {
         server_.terminateRenderRmd(true, new VoidServerRequestCallback());
      }
      shinyDoc_ = null;
   }
   
   private void cacheDocPosition(RmdRenderResult result, int scrollPosition, 
                                 String anchor)
   {
      if (result.isHtmlPresentation())
      {
         anchors_.put(keyFromResult(result), anchor);
      }
      else
      {
         scrollPositions_.put(keyFromResult(result), scrollPosition);
      }
   }
   
   // Generates lookup keys from results; used to enforce caching scroll 
   // position and/or anchor by document name and type 
   private String keyFromResult(RmdRenderResult result)
   {
      if (result.isShinyDocument())
         return result.getTargetFile();
      else
         return result.getOutputFile() + "-" + result.getFormatName();
   }
   
   private RmdOutputFrame createOutputFrame(int viewerType)
   {
      switch(viewerType)
      {
      case RMD_VIEWER_TYPE_WINDOW:
         return RStudioGinjector.INSTANCE.getRmdOutputFrameSatellite();
      case RMD_VIEWER_TYPE_PANE:
         return RStudioGinjector.INSTANCE.getRmdOutputFramePane();
      }
      return null;
   }

   private final GlobalDisplay globalDisplay_;
   private final FileTypeRegistry fileTypeRegistry_;
   private final UIPrefs prefs_;
   private final PDFViewer pdfViewer_;
   private final Provider<ViewFilePanel> pViewFilePanel_;
   private final RMarkdownServerOperations server_;
   private final EventBus events_;
   private boolean restarting_ = false;

   // stores the last scroll position of each document we know about: map
   // of path to position
   private final Map<String, Integer> scrollPositions_ = 
         new HashMap<String, Integer>();
   private final Map<String, String> anchors_ = 
         new HashMap<String, String>();
   private RmdRenderResult result_;
   private RmdShinyDocInfo shinyDoc_;
   private Operation onRenderCompleted_;
   private RmdOutputFrame outputFrame_;
   
   public final static int RMD_VIEWER_TYPE_WINDOW = 0;
   public final static int RMD_VIEWER_TYPE_PANE = 1;
   public final static int RMD_VIEWER_TYPE_NONE = 2;
}
