/*
 * main-window.ts
 *
 * Copyright (C) 2023 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 { ChildProcess } from 'child_process';
import { BrowserWindow, dialog, session, shell } from 'electron';

import { Err, safeError } from '../core/err';
import { logger } from '../core/logger';

import i18next from 'i18next';
import { setDockLabel } from '../native/dock.node';
import { appState, getEventBus } from './app-state';
import { ApplicationLaunch, LaunchRStudioOptions } from './application-launch';
import { DesktopBrowserWindow } from './desktop-browser-window';
import { GwtCallback, PendingQuit } from './gwt-callback';
import { GwtWindow } from './gwt-window';
import { MenuCallback } from './menu-callback';
import { ElectronDesktopOptions } from './preferences/electron-desktop-options';
import { RCommandEvaluator } from './r-command-evaluator';
import { SessionLauncher } from './session-launcher';
import { waitForUrlWithTimeout } from './url-utils';
import { isAutomated, registerWebContentsDebugHandlers } from './utils';

export function closeAllSatellites(mainWindow: BrowserWindow): void {
  const topLevels = BrowserWindow.getAllWindows();
  for (const win of topLevels) {
    if (win !== mainWindow) {
      win.close();
    }
  }
}

// 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 LOADING_WINDOW_WEBPACK_ENTRY: string;
declare const CONNECT_WINDOW_WEBPACK_ENTRY: string;

// number of times we've tried to reload in startup
let reloadCount = 0;

// amount of time to wait before each reload, in milliseconds
const reloadWaitDuration = 200;

// When launched with --automation-agent we pre-seed the WEBDIALOG cookie so
// that the GWT bootstrap loads the web-dialogs permutation on the very first
// load. base-prefs.jsonc (used by Playwright tests) sets
// native_file_dialogs: false; without the cookie pre-seed, GWT initially
// boots the native-dialogs permutation, Application.initializeWorkbench()
// detects the pref/cookie mismatch, and fires a ReloadEvent that costs ~4s
// per test launch. The expiry mirrors WebDialogCookie.java in GWT.
async function preSeedAutomationCookies(targetUrl: string): Promise<void> {
  if (!isAutomated()) {
    return;
  }

  try {
    await session.defaultSession.cookies.set({
      url: targetUrl,
      name: 'WEBDIALOG',
      value: '1',
      expirationDate: new Date(Date.UTC(5000, 11, 31)).getTime() / 1000,
    });
  } catch (err) {
    logger().logError(err as Error);
  }
}

export class MainWindow extends GwtWindow {
  static FIRST_WORKBENCH_INITIALIZED = 'main-window-first_workbench_initialized';
  static URL_CHANGED = 'main-window-url_changed';

  sessionLauncher?: SessionLauncher;
  appLauncher?: ApplicationLaunch;
  menuCallback: MenuCallback;
  quitConfirmed = false;
  geometrySaved = false;
  workbenchInitialized = false;

  private sessionProcess?: ChildProcess;
  private isErrorDisplayed = false;
  private didMainFrameLoadSuccessfully = true;

  constructor(url: string) {
    super({
      name: '',
      baseUrl: url,
      allowExternalNavigate: false,
      addApiKeys: ['desktop', 'desktopMenuCallback'],
    });

    appState().gwtCallback = new GwtCallback(this);
    this.menuCallback = new MenuCallback();

    RCommandEvaluator.setMainWindow(this);

    this.menuCallback.showPlaceholderMenu();

    this.menuCallback.on(MenuCallback.MENUBAR_COMPLETED, (/*menu: Menu*/) => {
      // We used to do `Menu.setApplicationMenu(menu);` here but that was causing crashes in
      // Electron when holding down the Alt key (https://github.com/rstudio/rstudio/issues/12983).
      // It seems some sort of clash between setting this here and the subsequent set
      // that happens in MenuCallback.updateMenus().
    });
    this.menuCallback.on(MenuCallback.COMMAND_INVOKED, (commandId) => {
      this.invokeCommand(commandId);
    });

    // TODO -- see comment in menu-callback.ts about: "probably need to not use the roles here"
    // connect(&menuCallback_, SIGNAL(zoomActualSize()), this, SLOT(zoomActualSize()));
    // connect(&menuCallback_, SIGNAL(zoomIn()), this, SLOT(zoomIn()));
    // connect(&menuCallback_, SIGNAL(zoomOut()), this, SLOT(zoomOut()));

    appState().gwtCallback?.on(GwtCallback.WORKBENCH_INITIALIZED, () => {
      this.emit(MainWindow.FIRST_WORKBENCH_INITIALIZED);
    });
    appState().gwtCallback?.on(GwtCallback.WORKBENCH_INITIALIZED, () => {
      this.onWorkbenchInitialized();
    });
    appState().gwtCallback?.once(GwtCallback.WORKBENCH_INITIALIZED, () => {
      appState().argsManager.handleAfterSessionLaunchCommands();
    });
    appState().gwtCallback?.on(GwtCallback.SESSION_QUIT, () => {
      this.onSessionQuit();
    });

    this.on(DesktopBrowserWindow.CLOSE_WINDOW_SHORTCUT, this.onCloseWindowShortcut.bind(this));

    registerWebContentsDebugHandlers(this.window.webContents);

    // Detect attempts to navigate to an external url in an iframe, and open the
    // url in an external browser instead of in the IDE.
    // Once https://github.com/electron/electron/pull/34418 is merged, we can leverage
    // the 'will-frame-navigate' event instead of intercepting the request.
    this.window.webContents.session.webRequest.onBeforeRequest((details, callback) => {
      logger().logDebug(`${details.method} ${details.url} [${details.resourceType}]`);

      // If `details.frame.url` is defined, navigation is being triggered in the iframe
      // (e.g. clicking on an anchor tag within an iframe) as opposed to a request to load
      // the url from the iframe src
      if (details.resourceType === 'subFrame' && details.frame?.url && !this.allowNavigation(details.url)) {
        // Open the page externally
        shell.openExternal(details.url).catch((error) => {
          logger().logError(error);
        });
        // Re-direct the iframe back to the source URL (bleh)
        // eslint thinks there's "Unnecessary optional chain on a non-nullish value", but
        // that seems like a false positive. Disabling the rule to avoid hitting the lint error.
        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
        callback({ cancel: false, redirectURL: details.frame?.url });
      } else {
        callback({ cancel: false });
      }
    });

    this.window.webContents.on('did-start-navigation', (event, url, isInPlace, isMainFrame) => {
      if (isMainFrame) {
        this.didMainFrameLoadSuccessfully = true;
      }
    });

    this.window.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL, isMainFrame) => {
      if (isMainFrame) {
        this.didMainFrameLoadSuccessfully = false;
      }
    });

    // NOTE: This callback is called regardless of whether the frame's page was
    // loaded successfully or not, so we need to detect failures to load within
    // 'did-fail-load' and then pass that state along here.
    this.window.webContents.on('did-frame-finish-load', async (event, isMainFrame) => {
      if (isMainFrame) {
        this.menuCallback.cleanUpActions();
        this.onLoadFinished(this.didMainFrameLoadSuccessfully);
      }
    });

    this.window.setTitle(appState().activation().editionName());
  }

  launchSession(reload: boolean): void {
    // we're about to start another session, so clear the workbench init flag
    // (will get set again once the new session has initialized the workbench)
    this.workbenchInitialized = false;

    // Reset GWT modal state when reloading the session to ensure modal dialog
    // state is synchronized between GWT and Electron after session restart
    if (reload) {
      appState().modalTracker.resetGwtModals();
    }

    const onLaunchError = (error: Err) => {
      logger().logError(error);

      appState().modalTracker.trackElectronModalSync(() =>
        dialog.showMessageBoxSync(this.window, {
          message: i18next.t('mainWindowTs.rSessionFailedToStart'),
          type: 'error',
          title: appState().activation().editionName(),
        }),
      );
      this.quit();
    };

    this.sessionLauncher
      ?.launchNextSession(reload)
      .then((error) => {
        if (error) {
          onLaunchError(error);
        }
      })
      .catch((err: unknown) => onLaunchError(safeError(err)));
  }

  launchRStudio(options: LaunchRStudioOptions): void {
    this.appLauncher?.launchRStudio(options);
  }

  onWorkbenchInitialized(): void {
    // reset state (in case this occurred in response to a manual reload
    // or reload for a new project context)
    this.quitConfirmed = false;
    this.geometrySaved = false;
    this.workbenchInitialized = true;
    getEventBus().emit('main-window-loaded');

    this.executeJavaScript('window.desktopHooks.getActiveProjectName()')
      .then((projectName) => {
        if (projectName.length > 0) {
          this.window.setTitle(`${projectName} - ${appState().activation().editionName()}`);
          setDockLabel(projectName);
        } else {
          this.window.setTitle(appState().activation().editionName());
          setDockLabel('');
        }
        this.avoidMoveCursorIfNecessary();
      })
      .catch((error) => {
        logger().logError(error);
      });
  }

  async loadUrl(url: string, updateBaseUrl = true): Promise<void> {
    // pass along the shared secret with every request
    const filter = {
      urls: [`${url}/*`],
    };
    session.defaultSession.webRequest.onBeforeSendHeaders(filter, (details, callback) => {
      details.requestHeaders['X-Shared-Secret'] = process.env.RS_SHARED_SECRET ?? '';
      callback({ requestHeaders: details.requestHeaders });
    });

    if (updateBaseUrl) {
      logger().logDebug(`Setting base URL: ${url}`);
      this.options.baseUrl = url;
    }

    await preSeedAutomationCookies(url);

    this.window.loadURL(url).catch((reason) => {
      logger().logErrorMessage(`Failed to load ${url}: ${reason}`);
    });
  }

  quit(): void {
    RCommandEvaluator.setMainWindow(null);
    this.quitConfirmed = true;
    if (!this.window.isDestroyed()) {
      this.window.close();
    } else {
      logger().logDebug('quit() called but BrowserWindow already destroyed');
    }
  }

  invokeCommand(cmdId: string): void {
    const cmd = `
      var wnd;
      try {
        wnd = window.$RStudio.last_focused_window;
        if (wnd && wnd.closed) wnd = null;
      } catch (e) {
        wnd = null;
      }
      (wnd || window).desktopHooks.invokeCommand('${cmdId}');`;
    this.executeJavaScript(cmd).catch((error) => {
      logger().logError(error);
    });
  }

  onSessionQuit(): void {
    closeAllSatellites(this.window);
    this.quit();
  }

  setSessionProcess(sessionProcess: ChildProcess | undefined): void {
    this.sessionProcess = sessionProcess;
  }

  closeEvent(event: Electron.Event): void {
    if (!this.geometrySaved) {
      const bounds = this.window.getNormalBounds();
      ElectronDesktopOptions().saveWindowBounds({ ...bounds, maximized: this.window.isMaximized() });
      this.geometrySaved = true;
    }

    if (this.quitConfirmed || !this.sessionProcess || this.sessionProcess.exitCode !== null) {
      closeAllSatellites(this.window);
      return;
    }

    const quit = () => {
      closeAllSatellites(this.window);
      this.quit();
    };

    event.preventDefault();
    this.executeJavaScript('!!window.desktopHooks')
      .then((hasQuitR: boolean) => {
        if (!hasQuitR) {
          logger().logErrorMessage('Main window closed unexpectedly');

          // exit to avoid user having to kill/force-close the application
          quit();
        } else {
          this.executeJavaScript('window.desktopHooks.quitR()')
            .then(() => (this.quitConfirmed = true))
            .catch(logger().logError);
        }
      })
      .catch(logger().logError);
  }

  collectPendingQuitRequest(): PendingQuit {
    return appState().gwtCallback?.collectPendingQuitRequest() ?? PendingQuit.PendingQuitNone;
  }

  onActivated(): void {
    // intentionally left blank
  }

  reload(): void {
    if (this.isErrorDisplayed) {
      return;
    }
    reloadCount++;
    this.loadUrl(this.options.baseUrl ?? '').catch(logger().logError);
  }

  onLoadFinished(ok: boolean): void {
    if (ok) {
      // we've successfully loaded!
    } else if (this.isErrorDisplayed) {
      // the session failed to launch and we're already showing
      // an error page to the user; nothing else to do here.
    } else {
      if (reloadCount === 0) {
        // the load failed, but we haven't yet received word that the
        // session has failed to load. let the user know that the R
        // session is still initializing, and then reload the page.
        this.loadUrl(LOADING_WINDOW_WEBPACK_ENTRY, false).catch(logger().logError);
        waitForUrlWithTimeout(this.options.baseUrl ?? '', reloadWaitDuration, reloadWaitDuration, 10)
          .then((error: Err) => {
            if (error) {
              logger().logError(error);
            }
          })
          .catch((error) => {
            logger().logError(error);
          })
          .finally(() => {
            this.reload();
          });
      } else {
        reloadCount = 0;
        this.onLoadFailed();
      }
    }
  }

  onLoadFailed(): void {
    if (this.isErrorDisplayed) {
      return;
    }

    const vars = new Map<string, string>();
    vars.set('retry_url', this.options.baseUrl ?? '');
    appState().gwtCallback?.setErrorPageInfo(vars);
    this.loadUrl(CONNECT_WINDOW_WEBPACK_ENTRY).catch(logger().logError);
  }

  setErrorDisplayed(): void {
    this.isErrorDisplayed = true;
  }
}
