import * as _ from 'lodash';
import { assert, driver, Key, stackWrapFunc, WebElement,
         WebElementPromise } from 'mocha-webdriver';
import { driverCompanion, findOldTimey, waitImpl,
         webdriverjqWrapper } from 'test/nbrowser/webdriverjq-nbrowser';
import * as guBase from 'test/nbrowser/gristUtils';
import { setupTestSuite } from 'test/nbrowser/testUtils';
import { server } from 'test/nbrowser/testServer';

// Simulate the old "$" object.

const _webdriverjqFactory = webdriverjqWrapper(driver);
function $(key) {
  if (typeof key !== 'string') {
    return key;
  }
  return _webdriverjqFactory(key);
}

function rep(n, value) {
  return _.times(n, () => value);
}

// The "$" object needs some setup done asynchronously.
// We do that later, during test initialization.
async function applyPatchesToJquerylikeObject($) {
  $.MOD = await guBase.modKey();
  $.COPY = await guBase.copyKey();
  $.CUT = await guBase.cutKey();
  $.PASTE = await guBase.pasteKey();
  $.SELECT_ALL = await guBase.selectAllKey();
  const capabilities = await driver.getCapabilities();
  if (capabilities.getBrowserName() === 'chrome' && await guBase.isMac()) {
    $.SELECT_ALL_LINES = (n) => [...rep(n, $.UP), $.HOME, $.SHIFT, ...rep(n, $.DOWN), $.END]
  } else {
    $.SELECT_ALL_LINES = () => $.SELECT_ALL;
  }
  $.getPage = async (url) => {
    return driver.get(url);
  };
  $.wait = (timeoutMs, conditionFunc) => {
    return waitImpl(timeoutMs, conditionFunc);
  }
  for (const key of Object.keys(Key)) {
    $[key] = Key[key];
  }
  // We need to tweak driver object a bit too (really?)
  driverCompanion.$ = $;
  // driver.testHost = server.getHost();
  // driver.waitImpl = waitImpl;
}

// Adapt common old setup.
const test = {
  setupTestSuite(self, ...args) {
    self.timeout(20000);
    return setupTestSuite(...args);
  },
};

// Add some methods to the grist utils that are used by old tests.
// This could be cleaned up further, but translating to newer ways
// of doing things or, if the method is really useful, adding the
// method to the new grist utils.

const waitForServer = guBase.waitForServer;
let patchesApplied = false;
let session;

const gu = {
  ...guBase,

  // Apply all needed patches, async initialization, and log in.
  async supportOldTimeyTestCode() {
    if (!patchesApplied) {
      applyPatchesToWebElements();
      applyPatchesToAssert();
      applyPatchesToJquerylikeObject($);
    }
    patchesApplied = true;
    // Login as someone so old code doesn't have to be upgraded to do it.
    session = await gu.session().user('userz');
    const dbManager = await server.getDatabase();
    const profile = {email: session.email, name: session.name};
    await dbManager.getUserByLogin(session.email, {profile});
    await gu.setApiKey(session.name);
    await session.login();
  },

  // getCell with old-timey arguments.
  getCellRC(r, c) {
    return gu.getCell(c, r + 1);
  },

  // clickCell with old-timey arguments.
  async clickCellRC(r, c) {
    const cell = gu.getCell(c, r + 1);
    await cell.click();
    return cell;
  },

  // sendKeys variant that accepts arrays in place of Key.chord.
  sendKeys(...args) {
    return guBase.sendKeys(...args.map(
      a => Array.isArray(a) ? Key.chord(...a) : a
    ));
  },

  /**
   * When doing type conversion in the side pane, this clicks the 'Apply' button and waits for the
   * conversion to complete.
   */
  async applyTypeConversion() {
    await $('.test-type-transform-apply').wait().scrollIntoView({
      block: 'nearest',
      inline: 'nearest',
    }).click();
    await $('.test-type-transform-apply').waitDrop(assert.isPresent, false);
    return gu.waitForServer();
  },

  async clickColumnMenuItem(colName, itemText, optRightClick) {
    await gu.openColumnMenu(colName);
    return gu.actions.selectFloatingOption(itemText);
  },

  getOpenEditingLabel(parentElem) {
    return driver.find('.test-column-title-label');
  },

  enterGridValues(startRowIndex, startColIndex, dataMatrix) {
    const transpose = dataMatrix[0].map(
      (_, colIndex) => dataMatrix.map(row => row[colIndex])
    );
    return gu.enterGridRows({col: startColIndex,
                             rowNum: startRowIndex + 1}, transpose);
  },

  async openSidePane(tabName) {
    if (['log', 'validate', 'repl', 'code'].includes(tabName)) {
      await guBase.toggleSidePanel('right', 'open');
      return $(`.test-tools-${tabName}`).wait().click();
    } else if (tabName === 'field') {
      await guBase.toggleSidePanel('right', 'open');
      return $('.test-right-tab-field').click();
    } else if (tabName === 'view') {
      await guBase.toggleSidePanel('right', 'open');
      return $('.test-right-tab-pagewidget').wait().click();
    }
  },

  getGridValues(...options) {
    return gu.getVisibleGridCells(...options);
  },

  /**
   * Returns the text in the row header of the last row in a Grid section, scrolling to the
   * bottom, but not moving the cursor.
   * @param {String} options.section: Optional section name to use instead of the active section.
   */
  async getGridLastRowText(options) {
    if (options?.section) {
      await gu.actions.viewSection(options.section).selectSection();
    }
    return String(await gu.getGridRowCount());
  },

  getAddRowNumber() {
    return gu.getGridRowCount();
  },

  async getGridLabels(sectionName) {
    return gu.getSection(sectionName).findAll('[data-test-id="GridView_columnLabel"] .kf_elabel_text',
                                              label => label.getText());
  },

  /**
   * Given a cell in grist GridView, returns whether it contains the cursor. You may use it as
   * hasCursor(getCell(...)) or getCell(...).waitFor(hasCursor, optTimeout).
   */
  hasCursor(cellElem) {
    return cellElem.find('.selected_cursor').isDisplayed();
  },

  async useFixtureDoc(cleanup, fname, flag) {
    return session.tempDoc(cleanup, fname);
  },

  async copyDoc(docId, flag) {
    const result = await guBase.copyDoc(session.name, 'docs', 'Home', docId);
    await session.loadDoc(`/doc/${result.id}`);
    return result;
  },

  async clickCell(rowIndexOrPosOrCell, colIndex) {
    if (typeof rowIndexOrPosOrCell === 'object' && 'driver_' in rowIndexOrPosOrCell) {
      return rowIndexOrPosOrCell.click();
    }
    // Best just to force a rewrite of clickCell, e.g. to clickCellRC,
    // since newer gristUtils interprets arguments entirely differently.
    if (typeof rowIndexOrPosOrCell === 'number') {
      throw new Error("ambiguous row/col");
    }
    const cell = guBase.getCell(rowIndexOrPosOrCell, colIndex);
    await cell.click();
  },

  /**
   * Sets the visibleCol of the currently selected field to value.
   */
  async setVisibleCol(value) {
    await gu.openSidePane('field');
    await $('.test-fbuilder-ref-col-select').click();
    await $(`.test-select-menu .test-select-row:contains(${value})`).wait().click();
    return waitForServer();
  },

  /**
   * Asserts the type of the currently selected field.
   */
  async assertType(value) {
    await gu.openSidePane('field');
    assert.equal(await $('.test-fbuilder-type-select .test-select-row').getText(), value);
  },

  closeSidePane() {
    return gu.toggleSidePanel('right', 'close');
  },

  clickVisibleDetailCells(column, rowNums) {
    return gu.getDetailCell(column, rowNums[0]).click();
  },

  async clickRowMenuItem(rowNum, item) {
    await (await gu.openRowMenu(rowNum)).findContent('li', item).click();
  },

  /**
   * Selects rows starting from rowStart and ending at rowEnd (1-based) by clicking and dragging or
   * shift clicking (Defaults to dragging)
   * @param {int} rowStart: 1-based row number
   * @param {int} rowEnd: 1-based row number.
   * @param {String} optMethod: if 'shift' then shift clicking is used to select rows otherwise it
   * defaults to drag to select.
   */
  async selectRows(rowStart, rowEnd, optMethod) {
    let start = await driver.findContent('.active_section .gridview_data_row_num', gu.exactMatch(rowStart.toString()));
    let end = await driver.findContent('.active_section .gridview_data_row_num', gu.exactMatch(rowEnd.toString()));
    if (optMethod === 'shift') {
      await driver.withActions(a => a.click(start).keyDown($.SHIFT).click(end).keyUp($.SHIFT));
    } else {
      await driver.withActions(a => a.move({origin: start}).press().move({origin: end}).release());
    }
  },

  async _fieldSettingsClickOption(isCommonToSeparate, optionSubstring) {
    assert.include(await $('.fieldbuilder_settings_button').text(), isCommonToSeparate ? 'Common' : 'Separate');
    await $('.fieldbuilder_settings_button').click();
    await gu.actions.selectFloatingOption(optionSubstring);
    await waitForServer();
    assert.include(await $('.fieldbuilder_settings_button').text(), isCommonToSeparate ? 'Separate' : 'Common');
  },
  fieldSettingsUseSeparate: () => gu._fieldSettingsClickOption(true, 'Use separate'),
  fieldSettingsSaveAsCommon: () => gu._fieldSettingsClickOption(false, 'Save as common'),
  fieldSettingsRevertToCommon: () => gu._fieldSettingsClickOption(false, 'Revert to common'),

  /**
   * Changes date format for date and datetime editor or returns current format
   * @param {string} value Date format
   */
  async dateFormat(value) {
    if (!value) {
      return $('$Widget_dateFormat .test-select-row').text();
    }
    await $('$Widget_dateFormat').wait().click();
    await $(`.test-select-menu .test-select-row:contains(${value})`).wait().click();
  },

  /**
   * Changes time format for datetime editor or returns current format
   * @param {string} value Time format
   */
  async timeFormat(value) {
    if (!value) {
      return $('$Widget_timeFormat .test-select-row').getText();
    }
    await $('$Widget_timeFormat').wait().click();
    await $(`.test-select-menu .test-select-row:contains(${value})`).wait().click();
  },

  async getDetailValues(...options) {
    return gu.getVisibleDetailCells(...options);
  },

  /**
  * Selects all cells in a GridView between and including startCell and endCell
  * @param {Array} startCell:
  *                         startCell[0]: 1-based row index.
  *                         startCell[1]: 0-based column index.
  * @param {Array} endCell:
  *                         endCell[0]: 1-based row index.
  *                         endCell[1]: 0-based column index.
  **/
  async selectGridArea(startCell, endCell) {
    let start = await gu.getCell({rowNum: startCell[0], col: startCell[1]});
    let end = await gu.getCell({rowNum: endCell[0], col: endCell[1]});
    await driver.withActions(a => a.click(start).keyDown($.SHIFT).click(end).keyUp($.SHIFT));
  },

  /**
   * Returns text of the cells for the given rows and columns of a viewSection.
   * @param {String} option.section: Optional section name instead of active.
   * @param {Array<Number>} option.rowNums: Array of row numbers (1-based)
   * @param {Array<Number>} option.cols: Array of column indices (0-based) or labels.
   * @param [Number: Function] option.cellFunc: a function that returns cells given an array of
   *      columns, rows and optionally a viewsection (defaults to the currently active section)
   * @param [Number: Function] option.valueFunc: Optional function, or an object mapping column
   *      index or label (as in options.cols) to function, with the function mapping a cell to
   *      its value (by default, cell => cell.text()).
   * @returns {Promise<Array>} Returns array of values for each requested cell, as all values from
   *      the first row, followed by values from the second, etc.
   */
  async getSectionValues(options) {
    var opts = { section: options.section };
    var defaultValueFunc = (cell => cell.text());
    var valueFunc;
    if (options.valueFunc && !_.isFunction(options.valueFunc)) {
      valueFunc = (col => options.valueFunc[col] || defaultValueFunc);
    } else {
      valueFunc = _.constant(options.valueFunc || defaultValueFunc);
    }
    const colValues = [];
    for (const col of options.cols) {
      const colValue = await valueFunc(col)(options.cellFunc(col, options.rowNums, opts).array());
      colValues.push(colValue);
    }
    return _.flatten(_.zip.apply(_, colValues), true);
  },

  /**
   * Asserts the widget of the currently selected field.
   */
  async assertWidget(value) {
    await gu.openSidePane('field');
    assert.equal(await $('.test-fbuilder-widget-select .test-select-row').getText(), value);
  },

  /**
   * Sets the widget of the currently selected field to value.
   */
  async setWidget(value) {
    await gu.openSidePane('field');
    const selector = $('.test-fbuilder-widget-select');
    const btnChildren = await selector.elem().findAll('.test-select-button');
    if (btnChildren.length > 0) {
      // This is a button select.
      await selector.findOldTimey(`.test-select-button:contains(${value})`).click();
    } else {
      // This is a dropdown select.
      await selector.click();
      await $(`.test-select-menu .test-select-row:contains(${value})`).click();
    }
    await gu.waitForServer();
  },

  /**
   * Adds a new record to the grid. Takes an array of values that matches column positions.
   */
  async addRecord(values) {
    await gu.sendKeys([$.MOD, $.UP]);
    await gu.sendKeys([$.MOD, $.DOWN]);
    await gu.sendKeys([$.LEFT]);
    await gu.sendKeys([$.LEFT]);
    await gu.sendKeys([$.LEFT]);
    await gu.sendKeys([$.LEFT]);
    await gu.sendKeys([$.LEFT]);
    await driver.sleep(1000);
    // For each value, type it, followed by Tab.
    for (const [i, value] of values.entries()) {
      await gu.waitAppFocus(true);
      await gu.sendKeys(value, $.TAB);
      await gu.waitForServer();
      if (i === 0) {
        // The very first value triggers add-record, but the creation of the new row isn't
        // immediate, so give it a moment.
        await driver.sleep(250);
      }
    }
    // Return a promise that can be awaited; it will wait for all the previously queued ones.
    return driver.sleep(0);
  },

  actions: {
    createNewDoc: async (optDocName) => {
      await gu.simulateLogin("Chimpy", "chimpy@getgrist.com", "nasa");
      const docId = await gu.createNewDoc('chimpy', 'nasa', 'Horizon', optDocName || 'Untitled');
      await gu.loadDoc(`/o/nasa/doc/${docId}`);
    },
    getDocTitle: () => {
      return $('.test-bc-doc').val();
    },
    getActiveTab: () => {
      return $('.test-treeview-itemHeader.selected .test-docpage-label').wait();
    },
    getTabs: () => {
      return $('.test-docpage-label');
    },
    renameDoc: (newName) => {
      $('.test-bc-doc').click();
      $.driver.sendKeys(newName, $.ENTER);
      return $.wait(1000, () => $.driver.getTitle().startsWith(newName + ' - '));
    },
    selectTabView: async (viewTitle) => {
      const isOpen = await gu.isSidePanelOpen('left');
      if (!isOpen) {
        await gu.toggleSidePanel('left', 'open');
      }
      await gu.openPage(viewTitle);
      if (!isOpen) {
        await gu.toggleSidePanel('left', 'close');
      }
    },
    addNewTable: async () => {
      await $('.test-dp-add-new').wait().click();
      await $('.test-dp-empty-table').click();
      // if we selected a new table, there will be a popup for a name
      const prompts = await $(".test-modal-prompt");
      const prompt = prompts[0];
      if (prompt) {
        await await $(".test-modal-confirm").click();
      }
      return gu.waitForServer();
    },
    addNewSection: (tableId, sectionType) => {
      return gu.addNewSection(sectionType, tableId);
    },
    addNewSummarySection: async (tableId, groupByArr, sectionType, sectionName) => {
      await gu.addNewSection(sectionType, tableId, {summarize: groupByArr});
      await gu.waitForServer();
      await gu.renameActiveSection(sectionName);
      await gu.waitForServer();
    },
    addNewView: (tableId, sectionType) => {
      return gu.addNewPage(sectionType, tableId);
    },
    selectFloatingOption: async (optionName) => {
      // Sometimes the element is there but "not interactable". Work around that.
      await gu.waitToPass(async () => {
        await $(`.grist-floating-menu li:contains(${optionName})`).click();
      });
    },
    /**
     * Actions related to view section. To use, pass in the section name.
     * @param {string} sectionName - Title of the view section
     * @return Object<string, function>} Collection of methods for the view section.
     *
     * @example
     * gu.actions.viewSection('Table1 record').selectMenuOption('Insert section');
     */
    viewSection: (sectionName) => {
      let section = gu.getSection(sectionName);
      return {
        /**
         * Clicks inside to make the current section active.
         */
        selectSection: function () {
          return gu.selectSectionByTitle(sectionName);
        },
        /**
         * Opens the view section drop-down menu.
         * @param {string} which - Which menu to open, coud be: 'sortAndFilter' or 'viewLayout'
         */
        openMenu: async function (which) {
          await driver.withActions(a => a.move({origin: section.find('.viewsection_title')})); // to display menu buttons on hover
          const item = section.find(`.test-section-menu-${which}`);
          await gu.waitToPass(() => item.click());
        },
        /**
         * Opens the section drop-down menu and select option matching param.
         * @param {string} which - Which menu to open, coud be: 'sortAndFilter' or 'viewLayout'
         * @param {string} optionName
         */
        selectMenuOption: function (which, optionName) {
          this.openMenu(which);
          return gu.actions.selectFloatingOption(optionName);
        }
      };
    },
    tableView: (tableName, viewName) => {
      return {
        select: () => {
          return gu.getPageItem(tableName).click();
        },
        selectOption: async optionName => {
          await gu.openPageMenu(tableName);
          return gu.actions.selectFloatingOption(optionName);
        }
      };
    }
  }
};

/**
 * This monkey-patches the WebElement class to make it look enough like
 * jquery that a lot of old test code can be used without modification.
 */
function applyPatchesToWebElements() {

  WebElement.prototype.wait = function(fn, ...args) {
    if (fn) {
      return gu.waitToPass(async () => {
        return fn.apply(null, [this, ...args]);
      }).then(() => true);
    } else {
      return new WebElementPromise(
        driver,
        gu.waitToPass(async () => {
          if (!(await this.isPresent())) {
            throw new Error('not present');
          }
        }).then(() => this));
    }
  }

  WebElement.prototype.selected = function(val) {
    return driver.executeScript((elem, val) => {
      elem.selected = val;
    }, this, val);
  }

  WebElement.prototype.attr = function(key, val) {
    if (val !== undefined) {
      return driver.executeScript((elem, key, val) => {
        elem.setAttribute(key, val);
      }, this, key, val);
    }
    return this.getAttribute(key);
  }

  WebElement.prototype.classList = async function() {
    return (await this.getAttribute('className')).split(' ');
  }

  // Lists of WebElements work differently - if we did a find() we
  // already have just the first match.
  WebElement.prototype.first = function() {
    return this;
  }

  WebElement.prototype.text = function() {
    return this.getText();
  }

  WebElement.prototype.val = function(newVal) {
    if (newVal === undefined) {
      return this.getAttribute('value');
    }
    return gu.setValue(this, newVal);
  }

  WebElement.prototype.css = function(key, val) {
    if (val === undefined) {
      return this.getCssValue(key);
    }
    return new WebElementPromise(
      driver,
      driver.executeScript(elem => {
        elem.style[key] = val;
      }, this)
    );
  }

  WebElement.prototype.is = function(selector) {
    return this.matches(selector);
  }

  WebElement.prototype.hasClass = async function(className) {
    return (await this.classList()).includes(className);
  }

  WebElement.prototype.scrollIntoView = function(opts) {
    opts = opts || {behavior: 'auto'};
    return new WebElementPromise(
      driver,
      driver.executeScript((elem, opts) => elem.scrollIntoView(opts),
                           this, opts).then(() => this));
  }

  WebElement.prototype.parent = function() {
    return new WebElementPromise(
      driver,
      driver.executeScript(elem => {
        return elem.parentNode.closest('*');
      }, this)
    );
  }

  WebElement.prototype.closest = function(key) {
    return this.findClosest(key);
  }

  WebElement.prototype.children = async function(mapper) {
    // Collect children.
    let result = await driver.executeScript(elem => {
      return [...elem.children].map(c => c.closest('*'));
    }, this);
    // Fix up type.
    result = result.map(v => new WebElementPromise(
      driver,
      Promise.resolve(v),
    ));
    // Apply mapper if available.
    if (mapper) {
      result = result.map(mapper);
    }
    // Result is a single promise.
    return Promise.all(result);
  }

  WebElement.prototype.trimmedText = async function() {
    const text = await this.getText();
    return text.trim();
  }

  // A version of find() that supports some old timey syntax.
  WebElement.prototype.findOldTimey = function(key) {
    return findOldTimey(this, key);
  }

  WebElement.prototype.findAllOldTimey = function(key, mapper) {
    return findOldTimey(this, key, true, mapper);
  }
}

/**
 * This monkey-patches assert to add some methods that are very
 * commonly used.
 */
function applyPatchesToAssert() {

  assert.hasClass = stackWrapFunc(async function(elem, className, present) {
    if (present === undefined) {
      present = true;
    }
    const c = await elem.getAttribute('class');
    if (present) {
      await assert.include(c.split(' '), className);
    } else {
      await assert.notInclude(c.split(' '), className);
    }
  });

  assert.isPresent = stackWrapFunc(async function(elem, present) {
    if (present === undefined) {
      present = true;
    }
    let current = false;
    try {
      current = await elem.isPresent();
    } catch (e) {
      // $ object may fail if elem is non-existent.
    }
    await assert.equal(current, present);
    return true;
  });

  assert.isDisplayed = stackWrapFunc(async function(elem, displayed) {
    if (displayed === undefined) {
      displayed = true;
    }
    await assert.equal(await elem.isDisplayed(), displayed);
    return true;
  });
}

exports.$ = $;
exports.gu = gu;
exports.server = server;
exports.test = test;
