import * as BaseView from 'app/client/components/BaseView';
import {Cursor} from 'app/client/components/Cursor';
import { GristDoc } from 'app/client/components/GristDoc';
import { get as getBrowserGlobals } from 'app/client/lib/browserGlobals';
import { CustomSectionElement, ViewProcess } from 'app/client/lib/CustomSectionElement';
import { Disposable } from 'app/client/lib/dispose';
import * as dom from 'app/client/lib/dom';
import * as kd from 'app/client/lib/koDom';
import * as DataTableModel from 'app/client/models/DataTableModel';
import { ViewFieldRec, ViewSectionRec } from 'app/client/models/DocModel';
import { CustomViewSectionDef } from 'app/client/models/entities/ViewSectionRec';
import {SortedRowSet} from 'app/client/models/rowset';
import {BulkColValues, fromTableDataAction, RowRecord} from 'app/common/DocActions';
import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes';
import { PluginInstance } from 'app/common/PluginInstance';
import {GristDocAPI, GristView} from 'app/plugin/GristAPI';
import {Events as BackboneEvents} from 'backbone';
import {MsgType, Rpc} from 'grain-rpc';
import * as ko from 'knockout';
import debounce = require('lodash/debounce');
import defaults = require('lodash/defaults');
import noop = require('lodash/noop');

const G = getBrowserGlobals('window');

/**
 * CustomView components displays arbitrary html. There are two modes available, in the "url" mode
 * the content is hosted by a third-party (for instance a github page), as opposed to the "plugin"
 * mode where the contents is provided by a plugin. In both cases the content is rendered safely
 * within an iframe (or webview if running electron). Configuration of the component is done within
 * the view config tab in the side pane. In "plugin" mode, shows notification if either the plugin
 * of the section could not be found.
 */
export class CustomView extends Disposable {

  /**
   * The HTMLElement embedding the content.
   */
  public viewPane: HTMLElement;

  // viewSection, sortedRows, tableModel, gristDoc, and cursor are inherited from BaseView
  protected viewSection: ViewSectionRec;
  protected sortedRows: SortedRowSet;
  protected tableModel: DataTableModel;
  protected gristDoc: GristDoc;
  protected cursor: Cursor;

  private _customDef: CustomViewSectionDef;

  // state of the component
  private _foundPlugin: ko.Observable<boolean>;
  private _foundSection: ko.Observable<boolean>;
  // Note the invariant: this._customSection != undefined if this._foundSection() == true
  private _customSection: ViewProcess|undefined;
  private _pluginInstance: PluginInstance|undefined;

  private _updateData: () => void;   // debounced call to let the view know linked data changed.
  private _updateCursor: () => void; // debounced call to let the view know linked cursor changed.
  private _rpc: Rpc;  // rpc connection to view.
  private _emptyWidgetPage: string;

  public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
    BaseView.call(this as any, gristDoc, viewSectionModel);

    this._customDef =  this.viewSection.customDef;

    this._emptyWidgetPage = new URL("custom-widget.html", gristDoc.app.topAppModel.getUntrustedContentOrigin()).href;

    this.autoDisposeCallback(() => {
      if (this._customSection) {
        this._customSection.dispose();
      }
    });
    this._foundPlugin = ko.observable(false);
    this._foundSection = ko.observable(false);
    // Ensure that selecting another section in same plugin update the view.
    this._foundSection.extend({notify: 'always'});

    this.autoDispose(this._customDef.pluginId.subscribe(this._updatePluginInstance, this));
    this.autoDispose(this._customDef.sectionId.subscribe(this._updateCustomSection, this));

    this.viewPane = this.autoDispose(this._buildDom());
    this._updatePluginInstance();

    this._updateData = debounce(() => this._updateView(true), 0);
    this._updateCursor = debounce(() => this._updateView(false), 0);

    this.autoDispose(this.viewSection.viewFields().subscribe(this._updateData));
    this.listenTo(this.sortedRows, 'rowNotify', this._updateData);
    this.autoDispose(this.sortedRows.getKoArray().subscribe(this._updateData));

    this.autoDispose(this.cursor.rowIndex.subscribe(this._updateCursor));
  }

  public async triggerPrint() {
    if (!this.isDisposed() && this._rpc) {
      return await this._rpc.callRemoteFunc("print");
    }
  }

  private _updateView(dataChange: boolean) {
    if (this.isDisposed()) { return; }
    if (this._rpc) {
      const state = {
        tableId: this.viewSection.table().tableId(),
        rowId: this.cursor.getCursorPos().rowId || undefined,
        dataChange
      };
      // tslint:disable-next-line:no-console
      this._rpc.postMessage(state).catch(e => console.error('Failed to send view state', e));
      // This post message won't get through if doc access has not been granted to the view.
    }
  }

  /**
   * Find a plugin instance that matchs the plugin id, update the `found` observables, then tries to
   * find a matching section.
   */
  private _updatePluginInstance() {

    const pluginId = this._customDef.pluginId();
    this._pluginInstance = this.gristDoc.docPluginManager.pluginsList.find(p => p.definition.id === pluginId);

    if (this._pluginInstance) {
      this._foundPlugin(true);
    } else {
      this._foundPlugin(false);
      this._foundSection(false);
    }
    this._updateCustomSection();
  }

  /**
   * If a plugin was found, find a custom section matching the section id and update the `found`
   * observables.
   */
  private _updateCustomSection() {

    if (!this._pluginInstance) { return; }

    const sectionId = this._customDef.sectionId();
    this._customSection = CustomSectionElement.find(this._pluginInstance, sectionId);

    if (this._customSection) {
      const el = this._customSection.element;
      el.classList.add("flexitem");
      this._foundSection(true);
    } else {
      this._foundSection(false);
    }

  }

  /**
   * Access data backing the section as a table.  This code is borrowed
   * with variations from ChartView.ts.
   */
  private _getSelectedTable(): BulkColValues {
    const fields: ViewFieldRec[] = this.viewSection.viewFields().all();
    const rowIds: number[] = this.sortedRows.getKoArray().peek() as number[];
    const data: BulkColValues = {};
    for (const field of fields) {
      // Use the colId of the displayCol, which may be different in case of Reference columns.
      const colId: string = field.displayColModel.peek().colId.peek();
      const getter = this.tableModel.tableData.getRowPropFunc(colId)!;
      const typeInfo = extractInfoFromColType(field.column.peek().type.peek());
      data[field.column().colId()] = rowIds.map((r) => reencodeAsAny(getter(r)!, typeInfo));
    }
    data.id = rowIds;
    return data;
  }

  private _getSelectedRecord(rowId: number): RowRecord {
    // Prepare an object containing the fields available to the view
    // for the specified row.  A RECORD()-generated rendering would be
    // more useful. but the data engine needs to know what information
    // the custom view depends on, so we shouldn't volunteer any untracked
    // information here.
    const fields: ViewFieldRec[] = this.viewSection.viewFields().all();
    const data: RowRecord = {id: rowId};
    for (const field of fields) {
      const colId: string = field.displayColModel.peek().colId.peek();
      const typeInfo = extractInfoFromColType(field.column.peek().type.peek());
      data[field.column().colId()] = reencodeAsAny(this.tableModel.tableData.getValue(rowId, colId)!, typeInfo);
    }
    return data;
  }

  private _buildDom() {
    const {mode, url, access} = this._customDef;
    const showPlugin = ko.pureComputed(() => this._customDef.mode() === "plugin");

    // When both plugin and section are not found, let's show only plugin notification.
    const showPluginNotification = ko.pureComputed(() => showPlugin() && !this._foundPlugin());
    const showSectionNotification = ko.pureComputed(() => showPlugin() && this._foundPlugin() && !this._foundSection());
    const showPluginContent = ko.pureComputed(() => showPlugin() && this._foundSection())
        // For the view to update when switching from one section to another one, the computed
        // observable must always notify.
        .extend({notify: 'always'});
    return dom('div.flexauto.flexvbox.custom_view_container',
      dom.autoDispose(showPlugin),
      dom.autoDispose(showPluginNotification),
      dom.autoDispose(showSectionNotification),
      dom.autoDispose(showPluginContent),
      // todo: should display content in webview when running electron
      kd.scope(() => [mode(), url(), access()], ([_mode, _url, _access]: string[]) =>
        _mode === "url" ? this._buildIFrame(_url, _access) : null),
      kd.maybe(showPluginNotification, () => buildNotification('Plugin ',
        dom('strong', kd.text(this._customDef.pluginId)), ' was not found',
        dom.testId('customView_notification_plugin')
      )),
      kd.maybe(showSectionNotification, () => buildNotification('Section ',
        dom('strong', kd.text(this._customDef.sectionId)), ' was not found in plugin ',
        dom('strong', kd.text(this._customDef.pluginId)),
        dom.testId('customView_notification_section')
      )),
      // When showPluginContent() is true then _foundSection() is also and _customSection is not
      // undefined (invariant).
      kd.maybe(showPluginContent, () => this._customSection!.element)
    );
  }

  private _buildIFrame(baseUrl: string, access: string) {
    // This is a url-flavored custom view.
    // Here we create an iframe, and add hooks for sending
    // messages to it and receiving messages from it.

    // Compute a url for the view.  We add in a parameter called "access"
    // so the page can determine what access level has been granted to it
    // in a simple and unambiguous way.
    let fullUrl: string;
    if (!baseUrl) {
      fullUrl = this._emptyWidgetPage;
    } else {
      const url = new URL(baseUrl);
      url.searchParams.append('access', access);
      fullUrl = url.href;
    }

    if (!access) { access = 'none'; }
    const someAccess = (access !== 'none');
    const fullAccess = (access === 'full');

    // Create an Rpc object to manage messaging.
    const rpc = new Rpc({});
    // Now, we create a listener for message events (if access was granted), making sure
    // to respond only to messages from our iframe.
    const listener = someAccess ? (event: MessageEvent) => {
      if (event.source === iframe.contentWindow) {
        // Previously, we forwarded messages targeted at "grist" to the back-end.
        // Now, we process them immediately in the context of the client for access
        // control purposes.  To do that, any message that comes in with mdest of
        // "grist" will have that destination wiped, and we provide a local
        // implementation of the interface.
        // It feels like it should be possible to deal with the mdest more cleanly,
        // with a rpc.registerForwarder('grist', { ... }), but it seems somehow hard
        // to call a locally registered interface of an rpc object?
        if (event.data.mdest === 'grist') {
          event.data.mdest = '';
        }
        rpc.receiveMessage(event.data);
        if (event.data.mtype === MsgType.Ready) {
          // After, the "ready" message, send a notification with cursor
          // (if available).
          this._updateView(true);
        }
      }
    } : null;
    // Add the listener only if some access has been granted.
    if (listener) { G.window.addEventListener('message', listener); }
    // Here is the actual iframe.
    const iframe = dom('iframe.custom_view.clipboard_focus',
                       {src: fullUrl},
                       dom.onDispose(() => {
                         if (listener) { G.window.removeEventListener('message', listener); }
                       }));
    if (someAccess) {
      // When replies come back, forward them to the iframe if access
      // is granted.
      rpc.setSendMessage(msg => {
        iframe.contentWindow!.postMessage(msg, '*');
      });
      // Register a way for the view to access the data backing the view.
      rpc.registerImpl<GristView>('GristView', {
        fetchSelectedTable: () => this._getSelectedTable(),
        fetchSelectedRecord: (rowId: number) => this._getSelectedRecord(rowId),
      });
      // Add a GristDocAPI implementation.  Apart from getDocName (which I think
      // is from a time before names and ids diverged, so I'm not actually sure
      // what it should return), require full access since the methods can view/edit
      // parts of the document beyond the table the widget is associated with.
      // Access rights will be that of the user viewing the document.
      // TODO: add something to calls to identify the origin, so it could be
      // controlled by access rules if desired.
      const assertFullAccess = () => {
        if (!fullAccess) { throw new Error('full access not granted'); }
      };
      rpc.registerImpl<GristDocAPI>('GristDocAPI', {
        getDocName: () => this.gristDoc.docId,
        listTables: async () => {
          assertFullAccess();
          // Could perhaps read tableIds from this.gristDoc.docModel.allTableIds.all()?
          const tables = await this.gristDoc.docComm.fetchTable('_grist_Tables');
          // Tables the user doesn't have access to are just blanked out.
          return tables[3].tableId.filter(tableId => tableId !== '');
        },
        fetchTable: async (tableId: string) => {
          assertFullAccess();
          return fromTableDataAction(await this.gristDoc.docComm.fetchTable(tableId));
        },
        applyUserActions: (actions: any[][]) => {
          assertFullAccess();
          return this.gristDoc.docComm.applyUserActions(actions, {desc: undefined});
        }
      });
    } else {
      // Direct messages to /dev/null otherwise.  Important to setSendMessage
      // or they will be queued indefinitely.
      rpc.setSendMessage(noop);
    }
    // We send events via the rpc object when the data backing the view changes
    // or the cursor changes.
    if (this._rpc) {
      // There's an existing RPC object we are replacing.
      // Unregister anything that may have been registered previously.
      // TODO: add a way to clean up more systematically to grain-rpc.
      this._rpc.unregisterForwarder('grist');
      this._rpc.unregisterImpl('GristDocAPI');
      this._rpc.unregisterImpl('GristView');
    }
    this._rpc = rpc;
    return iframe;
  }

  private listenTo(...args: any[]): void { /* replaced by Backbone */ }
}

// Getting an ES6 class to work with old-style multiple base classes takes a little hacking. Credits: ./ChartView.ts
defaults(CustomView.prototype, BaseView.prototype);
Object.assign(CustomView.prototype, BackboneEvents);


// helper to build the notification's frame.
function buildNotification(...args: any[]) {
  return dom('div.custom_view_notification.bg-warning', dom('p', ...args));
}