/*
 * desktop-browser-window.ts
 *
 * 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.
 *
 */

import { BrowserWindow, dialog, shell, WebContents } from 'electron';

import path from 'path';
import debounce from 'lodash/debounce';
import i18next from 'i18next';

import { EventEmitter } from 'stream';
import { URL } from 'url';
import { logger } from '../core/logger';
import { getenv } from '../core/environment';
import { appState, getEventBus } from './app-state';
import { showContextMenu } from './context-menu';
import { MainWindow } from './main-window';
import { ElectronDesktopOptions } from './preferences/electron-desktop-options';
import { ToolbarData, ToolbarManager } from './toolbar-manager';
import {
  getAuthority,
  isAboutUrl,
  isAllowedProtocol,
  isChromeGpuUrl,
  isLocalUrl,
  isSafeHost,
  makeAbsoluteUrl,
} from './url-utils';
import { executeJavaScript, handleLocaleCookies } from './utils';
import { positionAndEnsureVisible } from './window-utils';
import { properties } from '../../../../cpp/session/resources/schema/user-state-schema.json';

// This allows TypeScript to pick up the magic constants auto-generated by Forge's Webpack
// plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on
// whether you're running in development or production).
declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string;

export interface WindowConstructorOptions {
  /** Display a navigation toolbar; default is `false` */
  showToolbar?: boolean;

  /** Sync the window's title with the web content's title; default is `false` */
  adjustTitle?: boolean;

  /** Hide the menubar unless activated via Alt key; Default is `false` */
  autohideMenu?: boolean;

  /** Internal identifier for the window */
  name: string;

  /**
   * REVIEW: compare this against the Qt sources to determine we're using it as intended
   */
  baseUrl?: string;

  /** Parent of this window, if any */
  parent?: DesktopBrowserWindow;

  /** Web content that opened this window, if any */
  opener?: WebContents;

  /** Allow navigation to external domains; default is `false` */
  allowExternalNavigate?: boolean;

  /** Allows external links to be opened in the user's default browser */
  openExternalLinksInBrowser?: boolean;

  /** Callbacks to attach to the window; default is `desktopInfo` */
  addApiKeys?: string[];

  /** Attach to this `BrowserWindow` instead of creating a new one */
  existingWindow?: BrowserWindow;

  /** Skip locale cookie detection (for unit tests; otherwise we get
   * warnings about Possible EventEmitter memory leak on [cookies]) */
  skipLocaleDetection?: boolean;
}

/**
 * Base class for browser-based windows. Subclasses include GwtWindow, SecondaryWindow,
 * SatelliteWindow, and MainWindow.
 *
 * Porting note: This corresponds to a combination of the QMainWindow/BrowserWindow and
 * QWebEngineView/WebView in the Qt desktop app.
 */
export class DesktopBrowserWindow extends EventEmitter {
  static WINDOW_DESTROYED = 'desktop-browser-window_destroyed';
  static CLOSE_WINDOW_SHORTCUT = 'desktop-browser-close_window_shortcut';

  window: BrowserWindow;

  viewerUrl: string | undefined;
  tutorialUrl: string | undefined;
  presentationUrl: string | undefined;
  shinyDialogUrl: string | undefined;
  shinyDialogFullUrl: string | undefined;
  mainWindow?: MainWindow;

  // debouncing due to https://github.com/rstudio/rstudio/issues/13027
  positionAndEnsureVisibleDebounced = debounce(
    (window, requestedBounds, defaultWidth, defaultHeight) =>
      positionAndEnsureVisible(window, requestedBounds, defaultWidth, defaultHeight),
    75,
  );

  // if loading fails and emits `did-fail-load` it will be followed by a
  // 'did-finish-load'; use this bool to differentiate
  private failLoad = false;

  // if window has been told to delete its menu, we need to remember the bound
  // handler so we can unregister it when the window is closed
  private removeMenuBound?: () => void;

  // Track Alt key state to prevent menu bar activation during editor operations
  private altKeyDownTime = 0;
  private mouseDownDuringAlt = false;

  constructor(protected options: WindowConstructorOptions) {
    super();

    // set defaults for optional constructor arguments
    this.options.showToolbar = this.options.showToolbar ?? false;
    this.options.adjustTitle = this.options.adjustTitle ?? false;
    this.options.autohideMenu = this.options.autohideMenu ?? false;
    this.options.allowExternalNavigate = this.options.allowExternalNavigate ?? false;
    this.options.openExternalLinksInBrowser = this.options.openExternalLinksInBrowser ?? false;
    this.options.skipLocaleDetection = this.options.skipLocaleDetection ?? false;

    const apiKeys = [['--api-keys=desktopInfo', ...(this.options.addApiKeys ?? [])].join('|')];

    if (this.options.existingWindow) {
      this.window = this.options.existingWindow;
    } else {
      const preload = DesktopBrowserWindow.getPreload();

      // Match BACKGROUNDGRADIENT in themeStyles.css to reduce flashbulb effect while loading
      // https://github.com/rstudio/rstudio/issues/13768
      const backgroundColor = '#e1e2e5';

      this.window = new BrowserWindow({
        backgroundColor: backgroundColor,
        autoHideMenuBar: this.options.autohideMenu,
        webPreferences: {
          additionalArguments: apiKeys,
          contextIsolation: true,
          nodeIntegration: false,
          preload: preload,
          sandbox: true,
        },
        show: false,
        acceptFirstMouse: true,
      });

      if (!options.skipLocaleDetection) {
        void handleLocaleCookies(this.window, true);
      }

      const customStyles =
        '.gwt-SplitLayoutPanel-HDragger{cursor:ew-resize !important;} .gwt-SplitLayoutPanel-VDragger{cursor:ns-resize !important;}';

      this.window.webContents
        .insertCSS(customStyles, {
          cssOrigin: 'author',
        })
        .then((_result) => {
          logger().logDebug('Custom Styles Added Successfully');
        })
        .catch((error) => {
          logger().logError(error);
        });

      // Inject script to track Alt+mousedown on Windows
      if (process.platform === 'win32') {
        this.window.webContents.on('dom-ready', () => {
          const script = `
            window.addEventListener('mousedown', function(e) {
              if (e.altKey && window.desktop && window.desktop.notifyAltMouseDown) {
                window.desktop.notifyAltMouseDown();
              }
            }, true);
          `;
          this.window.webContents.executeJavaScript(script).catch((error) => {
            logger().logError('Failed to inject Alt+mousedown tracker: ' + error);
          });
        });
      }

      // Open dev tools automatically when RSTUDIO_OPEN_DEVTOOLS is set. Used
      // by the Playwright debug harness (waitForUserConsoleInput) to arm the
      // renderer's Performance profiler before a test drives its scenario.
      // Detached so the DevTools window doesn't reshape the app window layout.
      if (getenv('RSTUDIO_OPEN_DEVTOOLS') === '1') {
        this.window.webContents.openDevTools({ mode: 'detach' });
      }
    }

    // register context menu (right click) handler
    this.window.webContents.on('context-menu', (_event, params) => {
      const wc = this.window.webContents;
      showContextMenu(wc, params);
    });

    this.window.webContents.on('before-input-event', (event, input) => {
      this.keyPressEvent(event, input);
    });

    this.window.webContents.setWindowOpenHandler((details) => {
      // check for attempts to open a PDF vigette from the help system
      if (this.options.baseUrl) {
        const helpPrefix = `${this.options.baseUrl}/help/library/`;
        if (details.url.startsWith(helpPrefix)) {
          const reHelp = String.raw`/help/library/([^/]+)/doc/(.*)\.pdf`;
          const match = details.url.match(reHelp);
          if (match) {
            const args = [decodeURIComponent(match[2]), decodeURIComponent(match[1])];
            this.sendRpcRequest('show_vignette', args);
            return { action: 'deny' };
          }
        }
      }

      // check if this is target="_blank" from an IDE window
      if (
        this.options.baseUrl &&
        (details.disposition === 'foreground-tab' || details.disposition === 'background-tab')
      ) {
        // TODO: validation/restrictions on the URLs?
        void shell.openExternal(details.url);
        return { action: 'deny' };
      }

      // configure window creation; we'll associate the resulting BrowserWindow with our
      // window wrapper type via 'did-create-window' below
      return appState().windowOpening();
    });

    this.window.webContents.on('did-create-window', (newWindow) => {
      appState().windowCreated(newWindow, this.window.webContents, this.options.baseUrl);
    });

    // calling event.preventDefault() prevents navigation
    this.window.webContents.on('will-navigate', (event, url) => {
      // An iframe might try to force navigation to occur in the main frame;
      // for example, via target="_top". Detect these scenarios and open the
      // requested URL in an external window instead.
      //
      // https://github.com/rstudio/rstudio/issues/16624
      if (event.frame?.origin !== event.initiator?.origin) {
        event.preventDefault();
        void shell.openExternal(url);
        return;
      }

      if (!this.allowNavigation(url)) {
        event.preventDefault();
      }
    });

    this.window.webContents.on('page-title-updated', (event, title, explicitSet) => {
      this.adjustWindowTitle(title, explicitSet);
    });

    this.window.webContents.on('did-finish-load', () => {
      if (this.options.showToolbar) {
        const toolbarManager = new ToolbarManager();

        const toolbarData: ToolbarData = {
          buttons: [
            {
              tooltip: 'Go Back',
              iconPath: path.join(__dirname, 'assets', 'img', 'back.png'),
              onClick: `()=> {
                history.back();
              }`,
            },
            {
              tooltip: 'Go Forward',
              iconPath: path.join(__dirname, 'assets', 'img', 'forward.png'),
              onClick: `()=> {
                history.forward();
              }`,
            },
            {
              tooltip: 'Refresh Page',
              iconPath: path.join(__dirname, 'assets', 'img', 'reload.png'),
              onClick: `()=> {
                window.location.reload();
              }`,
            },
          ],
        };

        toolbarManager.createAndShowToolbar(this.window, toolbarData).catch((err) => {
          console.error('Error when trying to add Secondary Window toolbar', err);
        });
      }

      if (!this.failLoad) {
        this.finishLoading(true);
      } else {
        this.failLoad = false;
      }
    });

    this.window.webContents.on('did-fail-load', () => {
      this.failLoad = true;
      this.finishLoading(false);
    });

    this.window.on('close', (event: Electron.Event) => {
      this.removeMenuEventListener();
      this.closeEvent(event);
    });

    // when a page registers a 'beforeunload' handler that prevents unload
    // (e.g. a Shiny app warning that state will be lost on exit), Chromium
    // cancels the window close with no UI at all, leaving the window
    // seemingly impossible to close. mirror the browser behavior instead:
    // ask the user, and force the close if they choose to leave.
    //
    // note that Chromium does not expose the page's custom beforeunload
    // message, so a generic message is the best we can do here.
    //
    // https://github.com/rstudio/rstudio/issues/17439
    this.window.webContents.on('will-prevent-unload', (event: Electron.Event) => {
      if (getenv('RSTUDIO_DESKTOP_IGNORE_BEFOREUNLOAD') === '1') {
        logger().logDebug(`will-prevent-unload (${this.options.name}): forcing close (beforeunload ignored)`);
        event.preventDefault();
        return;
      }

      logger().logDebug(`will-prevent-unload (${this.options.name}): asking user to confirm leaving page`);

      const choice = appState().modalTracker.trackElectronModalSync(() =>
        dialog.showMessageBoxSync(this.window, {
          type: 'question',
          buttons: [
            i18next.t('desktopBrowserWindowTs.leavePageLeaveButton'),
            i18next.t('desktopBrowserWindowTs.leavePageStayButton'),
          ],
          defaultId: 0,
          cancelId: 1,
          title: i18next.t('desktopBrowserWindowTs.leavePageTitle'),
          message: i18next.t('desktopBrowserWindowTs.leavePageMessage'),
        }),
      );

      logger().logDebug(`will-prevent-unload (${this.options.name}): user chose ${choice === 0 ? 'leave' : 'stay'}`);

      if (choice === 0) {
        event.preventDefault();
      }
    });

    this.window.on('closed', () => {
      this.emit(DesktopBrowserWindow.WINDOW_DESTROYED);
    });

    this.window.on('ready-to-show', () => {
      // set zoom factor when window is ready
      // https://github.com/electron/electron/issues/10572
      const zoomLevel = ElectronDesktopOptions().zoomLevel();
      this.window.webContents.setZoomFactor(zoomLevel);
    });

    this.window.on('unmaximize', () => {
      // guard against restoring offscreen or too small
      // https://github.com/rstudio/rstudio/issues/12992
      this.positionAndEnsureVisibleDebounced(
        this.window,
        this.window.getBounds(),
        properties.view.default.windowBounds.width,
        properties.view.default.windowBounds.height,
      );
    });
  }

  // implementation of DesktopWebPage.cpp::acceptNavigationRequest;
  allowNavigation(url: string): boolean {
    logger().logDebug(`allowNavigation checking ${url}`);

    let targetUrl: URL;
    try {
      targetUrl = new URL(url);
    } catch (err: unknown) {
      // malformed URL will cause exception
      logger().logError(err);
      return false;
    }

    if (isAboutUrl(url) || isChromeGpuUrl(url)) {
      return true;
    }

    if (!isAllowedProtocol(targetUrl)) {
      logger().logDebug(`allowNavigation: disallowed protocol ${url}`);
      return false;
    }

    // determine if this is a local request (handle internally only if local)
    const isLocal = isLocalUrl(targetUrl);

    // iframe navigation interception: https://github.com/electron/electron/issues/7097
    // need 'will-frame-navigate' event
    // event handler should allow if the url is not local _and_ on the safe hosts list
    // see DesktopWebPage.cpp::acceptNavigationRequest where it checks s_subframes
    // workaround in GWT by calling DesktopFrame::allowNavigation to determine setting frame url

    const base = this.options.baseUrl ?? this.mainWindow?.options.baseUrl;
    if (!base && isLocal) {
      return true;
    }

    if (base) {
      const baseUrl = new URL(base);

      if (targetUrl.protocol === baseUrl.protocol && targetUrl.host === baseUrl.host) {
        return true;
      }
    }

    const viewer = this.viewerUrl ?? this.mainWindow?.viewerUrl;
    const tutorial = this.tutorialUrl ?? this.mainWindow?.tutorialUrl;
    const presentation = this.presentationUrl ?? this.mainWindow?.presentationUrl;
    const shinyDialog = this.shinyDialogUrl ?? this.mainWindow?.shinyDialogUrl;

    // the client is responsible for ensuring that non-local viewer/presentation/tutorial
    // urls are appropriately sandboxed
    if (
      (viewer && url.startsWith(viewer)) ||
      (presentation && url.startsWith(presentation)) ||
      (tutorial && url.startsWith(tutorial))
    ) {
      return true;
    }

    // allow shiny dialog urls
    if (isLocal && shinyDialog && url.startsWith(shinyDialog)) {
      return true;
    }

    if (this.options.allowExternalNavigate) {
      return true;
    } else if (isLocal || isSafeHost(targetUrl.hostname)) {
      // check isLocal until it is determined why restarting R
      // causes the old preview URL to load, even after the DOM
      // updates to use the new URL
      // https://github.com/rstudio/rstudio/issues/12256
      return true;
    } else if (this.options.openExternalLinksInBrowser) {
      void shell.openExternal(url);
      return false;
    } else {
      logger().logDebug(
        'allowNavigation:' +
          ' external navigation within IDE is not allowed and URL host is unsafe.' +
          ' URL must be explicitly opened in the browser.',
      );
      return false;
    }
  }

  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  closeEvent(event: Electron.Event): void {
    if (!this.options.opener) {
      // if we don't know where we were opened from, check window.opener
      // (note that this could also be empty)
      const cmd = `if (window.opener && window.opener.unregisterDesktopChildWindow)
           window.opener.unregisterDesktopChildWindow('${this.options.name}');`;
      this.executeJavaScript(cmd).catch((error) => {
        logger().logError(error);
      });
    } else {
      // if we do know where we were opened from and it has the appropriate
      // handlers, let it know we're closing
      const cmd = `if (window.unregisterDesktopChildWindow)
           window.unregisterDesktopChildWindow('${this.options.name}');`;
      this.executeJavaScript(cmd).catch((error) => {
        logger().logError(error);
      });
    }
  }

  adjustWindowTitle(title: string, explicitSet: boolean): void {
    if (this.options.adjustTitle && explicitSet) {
      this.window.setTitle(title);
    }
  }

  syncWindowTitle(): void {
    if (this.options.adjustTitle) {
      this.window.setTitle(this.window.webContents.getTitle());
    }
  }

  finishLoading(succeeded: boolean): void {
    if (succeeded) {
      this.syncWindowTitle();

      const cmd = `if (window.opener && window.opener.registerDesktopChildWindow)
         window.opener.registerDesktopChildWindow('${this.options.name}', window);`;
      this.executeJavaScript(cmd).catch((error) => {
        logger().logError(error);
      });
    }
  }

  avoidMoveCursorIfNecessary(): void {
    if (process.platform === 'darwin') {
      this.executeJavaScript('document.body.className = document.body.className + " avoid-move-cursor"').catch(
        (error) => {
          logger().logError(error);
        },
      );
    }
  }

  /**
   * Execute javascript in this window's page
   *
   * @param cmd javascript to execute in this window
   * @returns promise with result of execution
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async executeJavaScript(cmd: string): Promise<any> {
    return executeJavaScript(this.window.webContents, cmd);
  }

  setViewerUrl(viewerUrl: string): void {
    const url = makeAbsoluteUrl(viewerUrl);
    if (!isLocalUrl(url)) {
      return;
    }

    this.viewerUrl = getAuthority(viewerUrl);
  }

  setTutorialUrl(tutorialUrl: string): void {
    const url = makeAbsoluteUrl(tutorialUrl);
    if (!isLocalUrl(url)) {
      return;
    }

    this.tutorialUrl = getAuthority(tutorialUrl);
  }

  setPresentationUrl(presentationUrl: string): void {
    const url = makeAbsoluteUrl(presentationUrl);
    if (!isLocalUrl(url)) {
      return;
    }

    this.presentationUrl = getAuthority(presentationUrl);
  }

  setShinyDialogUrl(shinyDialogUrl: string): void {
    const url = makeAbsoluteUrl(shinyDialogUrl);
    if (!isLocalUrl(url)) {
      return;
    }

    this.shinyDialogUrl = getAuthority(shinyDialogUrl);
    this.shinyDialogFullUrl = makeAbsoluteUrl(shinyDialogUrl).toString();
  }

  injectShinyDialogCss(css: string): void {
    if (!this.shinyDialogFullUrl) return;

    let shinyOriginPath: string;
    try {
      const parsed = new URL(this.shinyDialogFullUrl);
      shinyOriginPath = (parsed.origin + parsed.pathname).replace(/\/+$/, '');
    } catch {
      return;
    }

    for (const frame of this.window.webContents.mainFrame.framesInSubtree) {
      let frameOriginPath: string;
      try {
        const parsed = new URL(frame.url);
        frameOriginPath = (parsed.origin + parsed.pathname).replace(/\/+$/, '');
      } catch {
        continue;
      }
      if (frameOriginPath === shinyOriginPath) {
        frame
          .executeJavaScript(
            `(function() {
              var s = document.createElement('style');
              s.textContent = ${JSON.stringify(css)};
              document.head.appendChild(s);
            })();`,
          )
          .catch(() => {
            // frame may have navigated away
          });
        break;
      }
    }
  }

  getBaseUrl(): string {
    return this.options.baseUrl ?? '';
  }

  keyPressEvent(event: Electron.Event, input: Electron.Input): void {
    if (process.platform === 'darwin') {
      if (input.meta && input.key.toLowerCase() === 'w') {
        // On macOS, intercept Cmd+W and emit the window close signal.
        // preventDefault() stops the global application menu accelerator
        // from also firing, which would route 'closeSourceDoc' to the
        // main window and close a source tab there (rstudio#17115).
        event.preventDefault();
        this.emit(DesktopBrowserWindow.CLOSE_WINDOW_SHORTCUT);
      }
    } else if (process.platform === 'win32') {
      // Handle Alt key on Windows to prevent menubar focus
      // We need to prevent the Alt key from activating the menu bar when
      // it's being used for multi-cursor selection in the editor
      if (input.key === 'Alt') {
        if (input.type === 'keyDown') {
          this.altKeyDownTime = Date.now();
        } else if (input.type === 'keyUp') {
          // Check if we should suppress this Alt keyup
          if (this.shouldSuppressAltKeyUp()) {
            event.preventDefault();
            return;
          }
          this.altKeyDownTime = 0;
        }
      }
    }
  }

  sendRpcRequest(name: string, value: object): void {
    const command = `window.sendRpcRequest("${name}", ${JSON.stringify(value)});`;
    this.executeJavaScript(command).catch((error) => {
      logger().logError(error);
    });
  }

  private shouldSuppressAltKeyUp(): boolean {
    // Suppress Alt keyUp only if mouse was clicked while Alt was held
    // (likely multi-cursor operation)
    const suppressDueToMouse = this.mouseDownDuringAlt;

    // Reset the flag
    this.mouseDownDuringAlt = false;

    return suppressDueToMouse;
  }

  notifyAltMouseDown(): void {
    // Called when mouse is clicked while Alt is held
    if (process.platform === 'win32' && this.altKeyDownTime > 0) {
      this.mouseDownDuringAlt = true;
    }
  }

  private removeMenu(): void {
    if (!this.window.isDestroyed()) {
      this.window.removeMenu();
    }
  }

  removeMenuEventListener(): void {
    if (this.removeMenuBound) {
      getEventBus().off('appmenu-set', this.removeMenuBound);
      this.removeMenuBound = undefined;
    }
  }

  /**
   * Remove the menu from this window
   */
  ensureNoMenu(): void {
    this.removeMenu();

    // We frequently recreate the main menu, so have to remove it every time that happens.
    if (this.removeMenuBound === undefined) {
      // only register one time
      this.removeMenuBound = this.removeMenu.bind(this);
      getEventBus().on('appmenu-set', this.removeMenuBound);
    }
  }

  /*
   * Close the window
   */
  close(): void {
    this.window.close();
  }

  /**
   *
   * @returns Path to preload script
   */
  static getPreload(): string {
    try {
      return MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY;
    } catch (_err: unknown) {
      // manually specify preload (necessary when running unit tests)
      return path.join(__dirname, '../renderer/preload.js');
    }
  }
}
