/**
 * The SafeBrowser component implementation is responsible for executing the safeBrowser component
 * of a plugin.
 *
 * A plugin's safeBrowser component is made of one main entry point (the javascript files declares
 * in the manifest), html files and any resources included by the html files (css, scripts, images
 * ...). The main script is the main entry point which uses the Grist API to render the views,
 * communicate with them en dispose them.
 *
 * The main script is executed within a WebWorker, and the html files are rendered within webviews
 * if run within electron, or iframe in case of the browser.
 *
 * Communication between the main process and the views are handle with rpc.
 *
 * If the plugins includes as well an unsafeNode component or a safePython component and if one of
 * them registers a function using the Grist Api, this function can then be called from within the
 * safeBrowser main script using the Grist API, as described in `app/plugin/Grist.ts`.
 *
 * The grist API available to safeBrowser components is implemented in `app/plugin/PluginImpl.ts`.
 *
 * All the safeBrowser's component resources, including the main script, the html files and any
 * other resources needed by the views, should be placed within one plugins' subfolder, and Grist
 * should serve only this folder. However, this is not yet implemented and is left as a TODO, as of
 * now the whole plugin's folder is served.
 *
 */
 // Todo: plugin resources should not be made available on the server by default, but only after
 // activation.

// tslint:disable:max-classes-per-file

import { ClientScope } from 'app/client/components/ClientScope';
import { get as getBrowserGlobals } from 'app/client/lib/browserGlobals';
import * as dom from 'app/client/lib/dom';
import * as Mousetrap from 'app/client/lib/Mousetrap';
import { ActionRouter } from 'app/common/ActionRouter';
import { BaseComponent, BaseLogger, createRpcLogger, PluginInstance, warnIfNotReady } from 'app/common/PluginInstance';
import { tbind } from 'app/common/tbind';
import { getOriginUrl } from 'app/common/urlUtils';
import { GristAPI, RPC_GRISTAPI_INTERFACE } from 'app/plugin/GristAPI';
import { RenderOptions, RenderTarget } from 'app/plugin/RenderOptions';
import { checkers } from 'app/plugin/TypeCheckers';
import { IpcMessageEvent, WebviewTag } from 'electron';
import { IMsgCustom, IMsgRpcCall, Rpc } from 'grain-rpc';
import { Disposable } from './dispose';
const G = getBrowserGlobals('document', 'window');

/**
 * The SafeBrowser component implementation. Responsible for running the script, rendering the
 * views, settings up communication channel.
 */
 // todo: it is unfortunate that SafeBrowser had to expose both `renderImpl` and `disposeImpl` which
 // really have no business outside of this module. What could be done, is to have an internal class
 // ProcessManager which will be created by SafeBrowser as a private field. It will manage the
 // client processes and among other thing will expose both renderImpl and
 // disposeImpl. ClientProcess will hold a reference to ProcessManager instead of SafeBrowser.
export class SafeBrowser extends BaseComponent {

  /**
   * Create a webview ClientProcess to render safe browser process in electron.
   */
  public static createWorker(safeBrowser: SafeBrowser, rpc: Rpc, src: string): WorkerProcess {
    return new WorkerProcess(safeBrowser, rpc, src);
  }

  /**
   * Create either an iframe or a webview ClientProcess depending on wether running electron or not.
   */
  public static createView(safeBrowser: SafeBrowser, rpc: Rpc, src: string): ViewProcess {
    return G.window.isRunningUnderElectron ?
      new WebviewProcess(safeBrowser, rpc, src) :
      new IframeProcess(safeBrowser, rpc, src);
  }

  // All view processes. This is not used anymore to dispose all processes on deactivation (this is
  // now achieved using `this._mainProcess.autoDispose(...)`) but rather to be able to dispatch
  // events to all processes (such as doc actions which will need soon).
  private _viewProcesses: Map<number, ClientProcess> = new Map();
  private _pluginId: string;
  private _pluginRpc: Rpc;
  private _mainProcess: WorkerProcess|undefined;
  private _viewCount: number = 0;

  constructor(
    private _plugin: PluginInstance,
    private _clientScope: ClientScope,
    private _untrustedContentOrigin: string,
    private _mainPath: string = "",
    private _baseLogger: BaseLogger = console,
    rpcLogger = createRpcLogger(_baseLogger, `PLUGIN ${_plugin.definition.id} SafeBrowser:`),
  ) {
    super(_plugin.definition.manifest, rpcLogger);
    this._pluginId = _plugin.definition.id;
    this._pluginRpc = _plugin.rpc;
  }

  /**
   * Render the file at path in an iframe or webview and returns its ViewProcess.
   */
  public createViewProcess(path: string): ViewProcess {
    return this._createViewProcess(path)[0];
  }
  /**
   * `receiveAction` handles an action received from the server by forwarding it to the view processes.
   */
  public receiveAction(action: any[]) {
    for (const view of this._viewProcesses.values()) {
      view.receiveAction(action);
    }
  }


  /**
   * Renders the file at path and returns its proc id. This is the SafeBrowser implementation for
   * the GristAPI's render(...) method, more details can be found at app/plugin/GristAPI.ts.
   */
  public async renderImpl(path: string, target: RenderTarget, options: RenderOptions): Promise<number> {
    const [proc, viewId] = this._createViewProcess(path);
    const renderFunc = this._plugin.getRenderTarget(target, options);
    renderFunc(proc.element);
    if (this._mainProcess) {
      // Disposing the web worker should dispose all view processes that created using the
      // gristAPI. There is a flaw here: please read [1].
      this._mainProcess.autoDispose(proc);
    }
    return viewId;
    // [1]: When a process, which is not owned by the mainProcess (ie: a process which was created
    // using `public createViewProcess(...)'), creates a view process using the gristAPI, the
    // rendered view will be owned by the main process. This is not correct and could cause views to
    // suddently disappear from the screen. This is pretty nasty. But for following reason I think
    // it's ok to leave it for now: (1) fixing this would require (yet) another refactoring of
    // SafeBrowser and (2) at this point it is not sure wether we want to keep `render()` in the
    // future (we could as well directly register contribution using files directly in the
    // manifest), and (3) plugins are only developed by us, we only have to remember that using
    // `render()` is only supported from within the main process (which cover all our use cases so
    // far).
  }

  /**
   * Dispose the process using it's proc id. This is the SafeBrowser implementation for the
   * GristAPI's dispose(...) method, more details can be found at app/plugin/GristAPI.ts.
   */
  public async disposeImpl(procId: number): Promise<void> {
    const proc = this._viewProcesses.get(procId);
    if (proc) {
      this._viewProcesses.delete(procId);
      proc.dispose();
    }
  }

  protected doForwardCall(c: IMsgRpcCall): Promise<any> {
    if (this._mainProcess) {
      return this._mainProcess.rpc.forwardCall(c);
    }
    // should not happen.
    throw new Error("Using SafeBrowser as an IForwarder requires a main script");
  }

  protected doForwardMessage(c: IMsgCustom): Promise<any> {
    if (this._mainProcess) {
      return this._mainProcess.rpc.forwardMessage(c);
    }
    // should not happen.
    throw new Error("Using SafeBrowser as an IForwarder requires a main script");
  }

  protected async activateImplementation(): Promise<void> {
    if (this._mainPath) {
      const rpc = this._createRpc(this._mainPath);
      const src = `plugins/${this._pluginId}/${this._mainPath}`;
      // This SafeBrowser object is registered with _pluginRpc as _mainPath forwarder, and
      // forwards calls to _mainProcess in doForward* methods (called from BaseComponent.forward*
      // methods). Note that those calls are what triggers component activation.
      this._mainProcess = SafeBrowser.createWorker(this, rpc, src);
    }
  }

  protected async deactivateImplementation(): Promise<void> {
    if (this._mainProcess) {
      this._mainProcess.dispose();
    }
  }

  /**
   * Creates an iframe or a webview embedding the file at path. And adds it to `this._viewProcesses`
   * using `viewId` as key, and registers it as forwarder to the `pluginRpc` using name
   * `path`. Unregister both on disposal.
   */
  private _createViewProcess(path: string): [ViewProcess, number] {
    const rpc = this._createRpc(path);
    const url = `${this._untrustedContentOrigin}/plugins/${this._plugin.definition.id}/${path}`
    + `?host=${G.window.location.origin}`;
    const viewId = this._viewCount++;
    const process = SafeBrowser.createView(this, rpc, url);
    this._viewProcesses.set(viewId, process);
    this._pluginRpc.registerForwarder(path, rpc);
    process.autoDisposeCallback(() => {
      this._pluginRpc.unregisterForwarder(path);
      this._viewProcesses.delete(viewId);
    });
    return [process, viewId];
  }

  /**
   * Create an rpc instance and set it up for communicating with a ClientProcess:
   *  - won't send any message before receiving a ready message
   *  - has the '*' forwarder set to the plugin's instance rpc
   *  - has registered an implementation of the gristAPI.
   * Returns the rpc instance.
   */
  private _createRpc(path: string): Rpc {
    const rpc = new Rpc({logger: createRpcLogger(this._baseLogger, `PLUGIN ${this._pluginId}/${path} SafeBrowser:`) });
    rpc.queueOutgoingUntilReadyMessage();
    warnIfNotReady(rpc, 3000, "Plugin isn't ready; be sure to call grist.ready() from plugin");
    rpc.registerForwarder('*', this._pluginRpc);
    // TODO: we should be able to stop serving plugins, it looks like there are some resources
    // required that should be disposed on component deactivation.
    this._clientScope.servePlugin(this._pluginId, rpc);
    return rpc;
  }


}

/**
 * Base class for any client process. `onDispose` allows to register a callback that will be
 * triggered when dispose() is called. This is for internally use.
 */
export class ClientProcess extends Disposable {
  public rpc: Rpc;

  private _safeBrowser: SafeBrowser;
  private _src: string;
  private _actionRouter: ActionRouter;

  public create(...args: any[]): void;
  public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {
    this.rpc = rpc;
    this._safeBrowser = safeBrowser;
    this._src = src;
    this._actionRouter = new ActionRouter(this.rpc);
    const gristAPI: GristAPI = {
      subscribe:    tbind(this._actionRouter.subscribeTable, this._actionRouter),
      unsubscribe:  tbind(this._actionRouter.unsubscribeTable, this._actionRouter),
      render:       tbind(this._safeBrowser.renderImpl, this._safeBrowser),
      dispose:      tbind(this._safeBrowser.disposeImpl, this._safeBrowser),
    };
    rpc.registerImpl<GristAPI>(RPC_GRISTAPI_INTERFACE, gristAPI, checkers.GristAPI);
    this.autoDisposeCallback(() => {
      this.rpc.unregisterImpl(RPC_GRISTAPI_INTERFACE);
    });
  }

  public receiveAction(action: any[]) {
    this._actionRouter.process(action)
      // tslint:disable:no-console
      .catch((err: any) => console.warn("ClientProcess[%s] receiveAction: failed with %s", this._src, err));
  }

}

/**
 * The web worker client process, used to execute safe browser main script.
 */
class WorkerProcess extends ClientProcess  {
  public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {
    super.create(safeBrowser, rpc, src);
    // Serve web worker script from same host as current page
    const worker = new Worker(getOriginUrl(`/${src}`));
    worker.addEventListener("message", (e: MessageEvent) => this.rpc.receiveMessage(e.data));
    this.rpc.setSendMessage(worker.postMessage.bind(worker));
    this.autoDisposeCallback(() => worker.terminate());
  }
}

export class ViewProcess extends ClientProcess {
  public element: HTMLElement;
}

/**
 * The Iframe ClientProcess used to render safe browser content in the browser.
 */
class IframeProcess extends ViewProcess {
  public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {
    super.create(safeBrowser, rpc, src);
    const iframe = this.element = this.autoDispose(dom(`iframe.safe_browser_process.clipboard_focus`,
      { src }));
    const listener = (event: MessageEvent) => {
      if (event.source === iframe.contentWindow) {
        this.rpc.receiveMessage(event.data);
      }
    };
    G.window.addEventListener('message', listener);
    this.autoDisposeCallback(() => {
      G.window.removeEventListener('message', listener);
    });
    this.rpc.setSendMessage(msg => iframe.contentWindow!.postMessage(msg, '*'));
  }

}

/**
 * The webview ClientProcess to render safe browser process in electron.
 */
class WebviewProcess extends ViewProcess {
  public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {
    super.create(safeBrowser, rpc, src);
    const webview: WebviewTag = this.element = this.autoDispose(dom('webview.safe_browser_process.clipboard_focus', {
      src,
      allowpopups: '',
      // Requests with this partition get an extra header (see main.js) to get access to plugin content.
      partition: 'plugins',
    }));
    // Temporaily disable "mousetrap" keyboard stealing for the duration of this webview.
    // This is acceptable since webviews are currently full-screen modals.
    // TODO: find a way for keyboard events to play nice when webviews are non-modal.
    Mousetrap.setPaused(true);
    this.autoDisposeCallback(() => Mousetrap.setPaused(false));
    webview.addEventListener('ipc-message', (event: IpcMessageEvent) => {
      // The event object passed to the listener is missing proper documentation. In the examples
      // listed in https://electronjs.org/docs/api/ipc-main the arguments should be passed to the
      // listener after the event object, but this is not happening here. Only we know it is a
      // DOMEvent with some extra porperties including a `channel` property of type `string` and an
      // `args` property of type `any[]`.
      if (event.channel === 'grist') {
        rpc.receiveMessage(event.args[0]);
      }
    });
    this.rpc.setSendMessage(msg => webview.send('grist', msg));
  }
}