/*
 * RmdOutput.java
 *
 * Copyright (C) 2022 by Posit Software, PBC
 *
 * Unless you have received this program directly from Posit Software pursuant
 * to the terms of a commercial license agreement with Posit Software, 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.HashMap;
import java.util.Map;

import org.rstudio.core.client.Debug;
import org.rstudio.core.client.StringUtil;
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.js.JsObject;
import org.rstudio.core.client.widget.Operation;
import org.rstudio.core.client.widget.OperationWithInput;
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.QuitInitiatedEvent;
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.filetypes.TextFileType;
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.PreviewRmdEvent;
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.RmdRenderPendingEvent;
import org.rstudio.studio.client.rmarkdown.events.RmdRenderStartedEvent;
import org.rstudio.studio.client.rmarkdown.events.RmdShinyDocStartedEvent;
import org.rstudio.studio.client.rmarkdown.events.WebsiteFileSavedEvent;
import org.rstudio.studio.client.rmarkdown.model.RMarkdownServerOperations;
import org.rstudio.studio.client.rmarkdown.model.RmdEditorOptions;
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.VoidResponse;
import org.rstudio.studio.client.server.VoidServerRequestCallback;
import org.rstudio.studio.client.workbench.WorkbenchContext;
import org.rstudio.studio.client.workbench.commands.Commands;
import org.rstudio.studio.client.workbench.model.Session;
import org.rstudio.studio.client.workbench.prefs.events.UserPrefsChangedEvent;
import org.rstudio.studio.client.workbench.prefs.model.UserPrefs;
import org.rstudio.studio.client.workbench.views.source.events.NotebookRenderFinishedEvent;

import com.google.gwt.core.client.GWT;
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,
                                  PreviewRmdEvent.Handler,
                                  RenderRmdEvent.Handler,
                                  RenderRmdSourceEvent.Handler,
                                  RestartStatusEvent.Handler,
                                  WebsiteFileSavedEvent.Handler,
                                  NotebookRenderFinishedEvent.Handler,
                                  RmdRenderPendingEvent.Handler,
                                  QuitInitiatedEvent.Handler,
                                  UserPrefsChangedEvent.Handler
{
   public interface Binder
   extends CommandBinder<Commands, RmdOutput> {}

   @Inject
   public RmdOutput(EventBus eventBus,
                    Commands commands,
                    Session session,
                    GlobalDisplay globalDisplay,
                    FileTypeRegistry fileTypeRegistry,
                    WorkbenchContext workbenchContext,
                    Provider<ViewFilePanel> pViewFilePanel,
                    Binder binder,
                    UserPrefs prefs,
                    PDFViewer pdfViewer,
                    RMarkdownServerOperations server)
   {
      globalDisplay_ = globalDisplay;
      fileTypeRegistry_ = fileTypeRegistry;
      workbenchContext_ = workbenchContext;
      pViewFilePanel_ = pViewFilePanel;
      prefs_ = prefs;
      pdfViewer_ = pdfViewer;
      server_ = server;
      events_ = eventBus;
      session_ = session;
      commands_ = commands;

      eventBus.addHandler(RmdRenderStartedEvent.TYPE, this);
      eventBus.addHandler(RmdRenderCompletedEvent.TYPE, this);
      eventBus.addHandler(RmdShinyDocStartedEvent.TYPE, this);
      eventBus.addHandler(PreviewRmdEvent.TYPE, this);
      eventBus.addHandler(RenderRmdEvent.TYPE, this);
      eventBus.addHandler(RenderRmdSourceEvent.TYPE, this);
      eventBus.addHandler(RestartStatusEvent.TYPE, this);
      eventBus.addHandler(UserPrefsChangedEvent.TYPE, this);
      eventBus.addHandler(WebsiteFileSavedEvent.TYPE, this);
      eventBus.addHandler(QuitInitiatedEvent.TYPE, this);
      eventBus.addHandler(RmdRenderPendingEvent.TYPE, this);
      eventBus.addHandler(NotebookRenderFinishedEvent.TYPE, this);

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

      binder.bind(commands, this);

      exportRmdOutputClosedCallback();
   }

   @Override
   public void onRmdRenderPending(RmdRenderPendingEvent event)
   {
      renderInProgress_ = true;
   }

   @Override
   public void onRmdRenderStarted(RmdRenderStartedEvent event)
   {
      if (Desktop.isDesktop())
      {
         // When an Office document starts rendering, tell the desktop frame
         // (if it exists) to get ready; this generally involves closing the
         // document in preparation for a refresh
         String format = event.getFormat().getFormatName();
         if (StringUtil.equals(format, RmdOutputFormat.OUTPUT_WORD_DOCUMENT))
            Desktop.getFrame().prepareShowWordDoc();
         else if (StringUtil.equals(format, RmdOutputFormat.OUTPUT_PPT_PRESENTATION))
            Desktop.getFrame().prepareShowPptPresentation();
      }
   }

   @Override
   public void onRmdRenderCompleted(RmdRenderCompletedEvent event)
   {
      renderInProgress_ = false;

      // 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 onPreviewRmd(final PreviewRmdEvent event)
   {
      RenderRmdEvent renderEvent = new RenderRmdEvent(
           event.getSourceFile(),
           1,
           null,
           event.getEncoding(),
           null,
           false,
           RmdOutput.TYPE_STATIC,
           event.getOutputFile(),
           null,
           null);
      events_.fireEvent(renderEvent);
   }

   @Override
   public void onRenderRmd(final RenderRmdEvent event)
   {
      // A document can specify a custom render command in its YAML `knit:`
      // field, which RStudio evaluates as R code when rendering. To protect
      // against a malicious document running arbitrary code on a single click
      // of the Knit/Preview button, confirm with the user before honoring a
      // custom render command (see rstudio-pro#10989). Known-safe and
      // RStudio-derived render functions do not require confirmation. A given
      // custom command is confirmed once per session; we re-query the backend
      // on every render (it only parses the YAML, it does not evaluate it) and
      // re-confirm if the command has changed since it was approved (e.g. the
      // file was replaced on disk after approval).
      final String sourceFile = event.getSourceFile();
      if (StringUtil.isNullOrEmpty(sourceFile))
      {
         doRenderRmd(event);
         return;
      }

      server_.getCustomRenderFunction(sourceFile,
            new ServerRequestCallback<JsObject>()
      {
         @Override
         public void onResponseReceived(JsObject response)
         {
            Boolean requiresConfirmation = response == null ? null :
               response.getBoolean("requires_confirmation");

            // fail closed: only skip the prompt when the backend explicitly
            // reports that confirmation is not required. A null/missing value
            // means we couldn't determine the render function, so confirm.
            if (Boolean.FALSE.equals(requiresConfirmation))
            {
               doRenderRmd(event);
               return;
            }

            String renderFunction = response == null ? "" :
               StringUtil.notNull(response.getString("render_function"));

            // skip the prompt only if the user already approved this exact
            // command for this file this session; a changed command re-prompts
            // (renderFunction is non-null, so this comparison is null-safe)
            if (renderFunction.equals(customRenderConfirmed_.get(sourceFile)))
            {
               doRenderRmd(event);
               return;
            }

            confirmCustomRender(event, renderFunction);
         }

         @Override
         public void onError(ServerError error)
         {
            // fail closed: if we can't read the render function, confirm with
            // the user rather than silently running a possibly-custom command
            Debug.logError(error);
            confirmCustomRender(event, "");
         }
      });
   }

   private void confirmCustomRender(final RenderRmdEvent event,
                                    final String renderFunction)
   {
      final String sourceFile = event.getSourceFile();

      // when we couldn't determine the specific render command, fall back to a
      // generic confirmation message rather than naming an empty command
      String message = StringUtil.isNullOrEmpty(renderFunction)
            ? constants_.customRenderConfirmMessageUnknown()
            : constants_.customRenderConfirmMessage(renderFunction);

      globalDisplay_.showYesNoMessage(
            GlobalDisplay.MSG_WARNING,
            constants_.customRenderConfirmCaption(),
            message,
            false,
            new Operation() {
               @Override
               public void execute()
               {
                  // remember the approved command so we don't re-prompt for it,
                  // but still re-prompt if the command later changes
                  customRenderConfirmed_.put(sourceFile, renderFunction);
                  doRenderRmd(event);
               }
            },
            new Operation() {
               @Override
               public void execute()
               {
                  // user declined: do not render. RmdRenderPendingEvent
                  // (fired before we got here) set renderInProgress_, so
                  // clear it since no render will start.
                  renderInProgress_ = false;
               }
            },
            false /* yesIsDefault */);
   }

   private void doRenderRmd(final RenderRmdEvent event)
   {
      quitInitiatedAfterLastRender_ = false;

      final Operation renderOperation = new Operation() {
         @Override
         public void execute()
         {
            renderInProgress_ = true;
            server_.renderRmd(event.getSourceFile(),
                              event.getSourceLine(),
                              event.getFormat(),
                              event.getEncoding(),
                              event.getParamsFile(),
                              event.asTempfile(),
                              event.getType(),
                              event.getExistingOutputFile(),
                              event.getWorkingDir(),
                              event.getViewerType(),
                  new SimpleRequestCallback<Boolean>() {
                       @Override
                       public void onError(ServerError error)
                       {
                          renderInProgress_ = false;
                       }
                  });
         }
      };

      // 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() == shinyDoc_.getFile() &&
          !shinyDoc_.getFormat().getFormatName().endsWith(
                RmdOutputFormat.OUTPUT_PRESENTATION_SUFFIX) &&
          (result_ == null || "shiny".equals(result_.getRuntime())))
      {
         final RmdRenderResult result =
               RmdRenderResult.createFromShinyDoc(shinyDoc_);
         displayHTMLRenderResult(result);
      }
      else
      {
         performRenderOperation(renderOperation);
      }
   }

   @Override
   public void onNotebookRenderFinished(NotebookRenderFinishedEvent event)
   {
      // ignore failed renders (e.g. cancelled, error)
      if (!event.succeeded())
         return;

      // ignore if no result, no output frame/closed output frame, or frame not
      // associated with this document
      if (result_ == null ||
          outputFrame_ == null ||
          outputFrame_.getWindowObject() == null ||
          outputFrame_.getWindowObject().isClosed() ||
          outputFrame_.getPreviewParams().getTargetFile() != event.getDocPath() ||
          !outputFrame_.getPreviewParams().getOutputFile().endsWith(".nb.html"))
        return;

      // redisplay the result
      displayRenderResult(result_);
   }

   @Override
   public void onWebsiteFileSaved(WebsiteFileSavedEvent event)
   {
      // auto reload/rerender on file saves (first apply various
      // filters to not auto reload). note that before we even
      // receive this event we know the file is one that is contained
      // in the website directory

      // skip if there is a build in progress
      if (workbenchContext_.isBuildInProgress())
         return;

      // skip if there is a render in progress
      if (renderInProgress_)
         return;

      // skip if there was a quit initiated since the last render
      if (quitInitiatedAfterLastRender_)
         return;

      // is there an output frame?
      if (outputFrame_ == null || outputFrame_.getWindowObject() == null)
         return;

      // is it showing a page from the current site?
      String websiteDir = session_.getSessionInfo().getBuildTargetDir();
      final RmdPreviewParams params = outputFrame_.getPreviewParams();
      if (!params.getTargetFile().startsWith(websiteDir))
         return;

      // is the changed file one that should always produce a rebuild?
      FileSystemItem file = event.getFileSystemItem();
      TextFileType fileType = fileTypeRegistry_.getTextTypeForFile(file);
      String typeId = fileType.getTypeId();
      if (fileType.isR() ||
          typeId == FileTypeRegistry.HTML.getTypeId() ||
          typeId == FileTypeRegistry.YAML.getTypeId() ||
          typeId == FileTypeRegistry.JSON.getTypeId())
      {
         reRenderPreview();
      }

      // is the changed file a markdown document
      else if (fileType.isMarkdown())
      {
         // included Rmd files always produce a rebuild of the current file
         if (file.getStem().startsWith("_"))
            reRenderPreview();

         // ...otherwise leave it alone (requires a knit)
      }

      // see if this should result in a copy + refresh
      else
      {
         server_.maybeCopyWebsiteAsset(file.getPath(),
               new SimpleRequestCallback<Boolean>() {
                  @Override
                  public void onResponseReceived(Boolean copied)
                  {
                     if (copied)
                        outputFrame_.showRmdPreview(params, true);
                  }
                });
      }
   }

   private void reRenderPreview()
   {
      reRenderPreview(null);
   }

   private void reRenderPreview(String targetFile)
   {
      if (outputFrame_ == null)
         return;

      livePreviewRenderInProgress_ = true;

      RmdPreviewParams params = outputFrame_.getPreviewParams();
      if (targetFile == null)
         targetFile = params.getTargetFile();

      RenderRmdEvent renderEvent = new RenderRmdEvent(
            targetFile,
            1,
            params.getResult().getFormatName(),
            params.getResult().getTargetEncoding(),
            null,
            false,
            RmdOutput.TYPE_STATIC,
            null,
            null,
            null);
       events_.fireEvent(renderEvent);
   }

   @Override
   public void onQuitInitiated(QuitInitiatedEvent event)
   {
      quitInitiatedAfterLastRender_ = true;
   }


   @Override
   public void onRenderRmdSource(final RenderRmdSourceEvent event)
   {
      quitInitiatedAfterLastRender_ = false;

      performRenderOperation(new Operation() {
         @Override
         public void execute()
         {
            server_.renderRmdSource(event.getSource(),
                                    new SimpleRequestCallback<>());
         }
      });
   }

   @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 onUserPrefsChanged(UserPrefsChangedEvent e)
   {
      onViewerTypeChanged(prefs_.rmdViewerType().getValue());
   }

   // Private methods ---------------------------------------------------------

   private void onViewerTypeChanged(String 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);
         if (outputFrame_ != null)
            outputFrame_.showRmdPreview(params, true);
      }
      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 && outputFrame_ != null)
      {
         // if we already have this up in the viewer pane, cache the scroll
         // position (we don't need to do this for the satellite since it
         // caches scroll position when it closes)
         if (result_ != null &&
             outputFrame_.getViewerType() == UserPrefs.RMD_VIEWER_TYPE_PANE)
         {
            cacheDocPosition(result_, outputFrame_.getScrollPosition(),
                  outputFrame_.getAnchor());
         }

         // 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<VoidResponse>()
         {
            @Override
            public void onResponseReceived(VoidResponse v)
            {
               onRenderCompleted_ = renderOperation;
               shinyDoc_ = null;
            }

            @Override
            public void onError(ServerError error)
            {
               globalDisplay_.showErrorMessage(constants_.shinyTerminalErrorCaption(),
                     constants_.shinyTerminalErrorMsg(shinyDoc_.getFile()));
            }
         });
      }
      else
      {
         renderOperation.execute();
      }
   }

   private void rerenderAsShiny(RmdRenderResult result)
   {
      events_.fireEvent(new RenderRmdEvent(
            result.getTargetFile(), result.getTargetLine(),
            null, result.getTargetEncoding(), null, false,
            RmdOutput.TYPE_SHINY, null, null, result.getViewerType()));
   }

   
   private void displayRenderResult(final RmdRenderResult result)
   {
      // don't display anything if user doesn't want to
      if (prefs_.rmdViewerType().getValue() == UserPrefs.RMD_VIEWER_TYPE_NONE ||
          result.getViewerType() == UserPrefs.RMD_VIEWER_TYPE_NONE)
         return;

      String outputFile = result.getOutputFile();
      if (StringUtil.isNullOrEmpty(outputFile))
         return;
      
      String extension = FileSystemItem.getExtensionFromPath(outputFile);
      if (".pdf".equals(extension))
      {
         String previewer = prefs_.pdfPreviewer().getValue();
         if (previewer == UserPrefs.PDF_PREVIEWER_RSTUDIO)
         {
            pdfViewer_.viewPdfUrl(
                  result.getOutputUrl(),
                  result.getPreviewSlide() >= 0 ?
                        result.getPreviewSlide() : null);
         }
         else if (previewer != UserPrefs.PDF_PREVIEWER_NONE)
         {
            if (Desktop.isDesktop())
               Desktop.getFrame().showPDF(StringUtil.notNull(result.getOutputFile()),
                                          result.getPreviewSlide());
            else
               globalDisplay_.showHtmlFile(result.getOutputFile());
         }
      }
      else if (".docx".equals(extension) ||
               ".rtf".equals(extension) ||
               ".odt".equals(extension))
      {
         RmdOutputDisplay.displayWordDoc(result.getTargetFile(), result.getOutputFile());
      }
      else if (".pptx".equals(extension))
      {
         RmdOutputDisplay.displayPptDoc(result.getTargetFile(), result.getOutputFile());
      }
      else if (".html".equals(extension) ||
               NOTEBOOK_EXT.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
      {
         RmdOutputDisplay.displayBinaryDoc(result.getTargetFile(), result.getOutputFile());
      }
   }

   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);

      // get the default viewer type from prefs
      String viewerType = prefs_.rmdViewerType().getValue();

      // apply override from result, if any
      if (result.getViewerType() == RmdEditorOptions.PREVIEW_IN_VIEWER)
         viewerType = UserPrefs.RMD_VIEWER_TYPE_PANE;
      else if (result.getViewerType() == RmdEditorOptions.PREVIEW_IN_WINDOW)
         viewerType = UserPrefs.RMD_VIEWER_TYPE_WINDOW;
      else if (result.getViewerType() == RmdEditorOptions.PREVIEW_IN_NONE)
         viewerType = UserPrefs.RMD_VIEWER_TYPE_NONE;

      // don't host presentations in the viewer pane--ioslides doesn't scale
      // slides well without help
      if (result.isHtmlPresentation() && viewerType == UserPrefs.RMD_VIEWER_TYPE_PANE)
         viewerType = UserPrefs.RMD_VIEWER_TYPE_WINDOW;

      final String newViewerType = viewerType;

      // 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 == UserPrefs.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() != 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, String viewerType,
                                    RmdPreviewParams params)
   {
      if (viewerType == UserPrefs.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() == 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());
      }

      boolean isNotebook = result_ != null &&
            FileSystemItem.getExtensionFromPath(result_.getOutputFile()) ==
            NOTEBOOK_EXT;

      // show the preview; activate the window (but not for auto-refresh of
      // notebook preview)
      outputFrame_.showRmdPreview(params, !(isRefresh && isNotebook &&
            result.viewed()));
      result.setViewed(true);

      // reset live preview state
      livePreviewRenderInProgress_ = false;

      // 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(String viewerType)
   {
      switch(viewerType)
      {
      case UserPrefs.RMD_VIEWER_TYPE_WINDOW:
         return RStudioGinjector.INSTANCE.getRmdOutputFrameSatellite();
      case UserPrefs.RMD_VIEWER_TYPE_PANE:
         return RStudioGinjector.INSTANCE.getRmdOutputFramePane();
      }
      return null;
   }

   private final GlobalDisplay globalDisplay_;
   private final FileTypeRegistry fileTypeRegistry_;
   private final UserPrefs prefs_;
   private final PDFViewer pdfViewer_;
   private final Provider<ViewFilePanel> pViewFilePanel_;
   private final RMarkdownServerOperations server_;
   private final Session session_;
   private final EventBus events_;
   private final Commands commands_;
   private final WorkbenchContext workbenchContext_;
   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<>();
   private final Map<String, String> anchors_ = new HashMap<>();
   private RmdRenderResult result_;
   private RmdShinyDocInfo shinyDoc_;
   private Operation onRenderCompleted_;
   private RmdOutputFrame outputFrame_;
   private boolean renderInProgress_ = false;
   private boolean livePreviewRenderInProgress_ = false;
   private boolean quitInitiatedAfterLastRender_ = false;

   // maps a source file to the custom render command the user has confirmed for
   // it this session, so we only prompt once per command but re-prompt if the
   // command changes (e.g. the file is replaced on disk after approval)
   private final Map<String, String> customRenderConfirmed_ = new HashMap<>();

   public final static String NOTEBOOK_EXT = ".nb.html";

   public final static int TYPE_STATIC   = 0;
   public final static int TYPE_SHINY    = 1;
   public final static int TYPE_NOTEBOOK = 2;

   private static final RMarkdownConstants constants_ = GWT.create(RMarkdownConstants.class);
}
