/*****************************************************************************\
|                                               ( )_  _                       |
|    _ _    _ __   _ _    __    ___ ___     _ _ | ,_)(_)  ___   ___     _     |
|   ( '_`\ ( '__)/'_` ) /'_ `\/' _ ` _ `\ /'_` )| |  | |/',__)/' v `\ /'_`\   |
|   | (_) )| |  ( (_| |( (_) || ( ) ( ) |( (_| || |_ | |\__, \| (˅) |( (_) )  |
|   | ,__/'(_)  `\__,_)`\__  |(_) (_) (_)`\__,_)`\__)(_)(____/(_) (_)`\___/'  |
|   | |                ( )_) |                                                |
|   (_)                 \___/'                                                |
|                                                                             |
| General Bots Copyright (c) Pragmatismo.io. All rights reserved.             |
| Licensed under the AGPL-3.0.                                                |
|                                                                             |
| According to our dual licensing model, this program can be used either      |
| under the terms of the GNU Affero General Public License, version 3,        |
| or under a proprietary license.                                             |
|                                                                             |
| The texts of the GNU Affero General Public License with an additional       |
| permission and of our proprietary license can be found at and               |
| in the LICENSE file you have received along with this program.              |
|                                                                             |
| This program is distributed in the hope that it will be useful,             |
| but WITHOUT ANY WARRANTY, without even the implied warranty of              |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the                |
| GNU Affero General Public License for more details.                         |
|                                                                             |
| "General Bots" is a registered trademark of Pragmatismo.io.                 |
| The licensing of the program under the AGPLv3 does not imply a              |
| trademark license. Therefore any rights, title and interest in              |
| our trademarks remain entirely with us.                                     |
|                                                                             |
\*****************************************************************************/
'use strict';
import { GBDialogStep, GBLog, GBMinInstance } from 'botlib';
import { GBConfigService } from '../../core.gbapp/services/GBConfigService.js';
import { CollectionUtil } from 'pragmatismo-io-framework';

import { GBAdminService } from '../../admin.gbapp/services/GBAdminService.js';
import { GBDeployer } from '../../core.gbapp/services/GBDeployer.js';
import { DialogKeywords } from './DialogKeywords.js';
import { GBServer } from '../../../src/app.js';
import { GBVMService } from './GBVMService.js';
import Fs from 'fs';
import { createBrowser } from '../../core.gbapp/services/GBSSR.js';
import urlJoin from 'url-join';
import Excel from 'exceljs';
import { TwitterApi } from 'twitter-api-v2';
import tesseract from 'node-tesseract-ocr';
import Path from 'path';
import ComputerVisionClient from '@azure/cognitiveservices-computervision';
import ApiKeyCredentials from '@azure/ms-rest-js';
import alasql from 'alasql';
import PizZip from 'pizzip';
import Docxtemplater from 'docxtemplater';
import pptxTemplaterModule from 'pptxtemplater';
import _ from 'lodash';

/**
 * @fileoverview General Bots server core.
 */

/**
 * BASIC system class for extra manipulation of bot behaviour.
 */
export class SystemKeywords {
  /**
   * Reference to minimal bot instance.
   */
  public min: GBMinInstance;

  /**
   * Reference to the deployer service.
   */
  private readonly deployer: GBDeployer;

  dk: DialogKeywords;
  wa;

  /**
   * When creating this keyword facade, a bot instance is
   * specified among the deployer service.
   */
  constructor (min: GBMinInstance, deployer: GBDeployer, dk: DialogKeywords, wa) {
    this.min = min;
    this.wa = wa;
    this.deployer = deployer;
    this.dk = dk;
  }

  public async callVM ({ text }) {
    // TODO:
    const min = null;
    const step = null;
    const deployer = null;

    return await GBVMService.callVM(text, min, step, deployer, false);
  }

  public async append ({ args }) {
    let array = [].concat(...args);
    return array.filter(function (item, pos) {
      return item;
    });
  }

  /**
   *
   * @example SEE CAPTION OF url AS variable
   *
   */
  public async seeCaption ({ url }) {
    const computerVisionClient = new ComputerVisionClient.ComputerVisionClient(
      new ApiKeyCredentials.ApiKeyCredentials({ inHeader: { 'Ocp-Apim-Subscription-Key': process.env.VISION_KEY } }),
      process.env.VISION_ENDPOINT
    );

    let caption = (await computerVisionClient.describeImage(url)).captions[0];

    const contentLocale = this.min.core.getParam<string>(
      this.min.instance,
      'Default Content Language',
      GBConfigService.get('DEFAULT_CONTENT_LANGUAGE')
    );
    GBLog.info(`GBVision (caption): '${caption.text}' (Confidence: ${caption.confidence.toFixed(2)})`);

    return await this.min.conversationalService.translate(this.min, caption.text, contentLocale);
  }

  /**
   *
   * @example SEE TEXT OF url AS variable
   *
   */
  public async seeText ({ url }) {
    const computerVisionClient = new ComputerVisionClient.ComputerVisionClient(
      new ApiKeyCredentials.ApiKeyCredentials({ inHeader: { 'Ocp-Apim-Subscription-Key': process.env.VISION_KEY } }),
      process.env.VISION_ENDPOINT
    );

    const result = await computerVisionClient.recognizePrintedText(true, url);
    const text = result.regions[0].lines[0].words[0].text;
    let final = '';

    for (let i = 0; i < result.regions.length; i++) {
      const region = result.regions[i];

      for (let j = 0; j < region.lines.length; j++) {
        const line = region.lines[j];

        for (let k = 0; k < line.words.length; k++) {
          final += `${line.words[k].text} `;
        }
      }
    }

    GBLog.info(`GBVision (text): '${final}'`);
    return final;
  }

  public async sortBy ({ array, memberName }) {
    memberName = memberName.trim();
    const contentLocale = this.min.core.getParam<string>(
      this.min.instance,
      'Default Content Language',
      GBConfigService.get('DEFAULT_CONTENT_LANGUAGE')
    );

    // Detects data type from the first element of array.

    let dt = array[0] ? array[0][memberName] : null;
    let date = SystemKeywords.getDateFromLocaleString(dt, contentLocale);
    if (date) {
      return array
        ? array.sort((a, b) => {
            const c = new Date(a[memberName]);
            const d = new Date(b[memberName]);
            return c.getTime() - d.getTime();
          })
        : null;
    } else {
      return array
        ? array.sort((a, b) => {
            if (a[memberName] < b[memberName]) {
              return -1;
            }
            if (a[memberName] > b[memberName]) {
              return 1;
            }
            return 0;
          })
        : array;
    }
  }

  public static JSONAsGBTable (data, headers) {
    try {
      let output = [];
      let isObject = false;

      if (Array.isArray(data)) {
        isObject = Object.keys(data[1]) !== null;
      }

      if (isObject || JSON.parse(data) !== null) {
        let keys = Object.keys(data[1]);

        if (headers) {
          output[0] = [];
          // Copies headers as the first element.

          for (let i = 0; i < keys.length; i++) {
            output[0][i] = keys[i];
          }
        } else {
          output.push({ gbarray: '0' });
        }

        // Copies data from JSON format into simple array.

        for (let i = 0; i < data.length; i++) {
          output[i + 1] = [];
          for (let j = 0; j < keys.length; j++) {
            output[i + 1][j] = data[i][keys[j]];
          }
        }

        return output;
      }
    } catch (error) {
      GBLog.error(error);
      return data;
    }
  }

  /**
   *
   * @param data
   * @param renderPDF
   * @param renderImage
   * @returns
   *
   * @see http://tabulator.info/examples/5.2
   */
  private async renderTable (data, renderPDF, renderImage) {
    if (!data[1]) {
      return null;
    }

    data = SystemKeywords.JSONAsGBTable(data, true);

    // Detects if it is a collection with repeated
    // headers.

    const gbaiName = `${this.min.botId}.gbai`;
    const browser = await createBrowser(null);
    const page = await browser.newPage();

    // Includes the associated CSS related to current theme.

    const theme = this.dk.user.basicOptions.theme;
    switch (theme) {
      case 'white':
        await page.addStyleTag({ path: 'node_modules/tabulator-tables/dist/css/tabulator_simple.min.css' });
        break;
      case 'dark':
        await page.addStyleTag({ path: 'node_modules/tabulator-tables/dist/css/tabulator_midnight.min.css' });
        break;
      case 'blue':
        await page.addStyleTag({ path: 'node_modules/tabulator-tables/dist/css/tabulator_modern.min.css' });
        break;
      default:
        break;
    }

    await page.addScriptTag({ path: 'node_modules/tabulator-tables/dist/js/tabulator.min.js' });

    // Removes internal hidden element used to hold one-based index arrays.

    data.shift();

    // Guess fields from data variable into Tabulator fields collection.

    let fields = [];
    let keys = Object.keys(data[0]);
    for (let i = 0; i < keys.length; i++) {
      fields.push({ field: keys[i], title: keys[i] });
    }

    // Adds DIV for Tabulator.

    await page.evaluate(() => {
      const el = document.createElement('div');
      el.setAttribute('id', 'table');
      document.body.appendChild(el);
    });

    const code = `
        var table = new Tabulator("#table", {
        height:"311px",
        layout:"fitColumns",
        data: ${JSON.stringify(data)},
        columns: ${JSON.stringify(fields)}
    });
    `;
    await page.evaluate(code);
    await page.waitForSelector('#table');

    // Handles image generation.

    let url;
    let localName;
    if (renderImage) {
      localName = Path.join('work', gbaiName, 'cache', `img${GBAdminService.getRndReadableIdentifier()}.png`);
      await page.screenshot({ path: localName, fullPage: true });
      url = urlJoin(GBServer.globals.publicAddress, this.min.botId, 'cache', Path.basename(localName));
      GBLog.info(`BASIC: Table image generated at ${url} .`);
    }

    // Handles PDF generation.

    if (renderPDF) {
      localName = Path.join('work', gbaiName, 'cache', `img${GBAdminService.getRndReadableIdentifier()}.pdf`);
      url = urlJoin(GBServer.globals.publicAddress, this.min.botId, 'cache', Path.basename(localName));
      let pdf = await page.pdf({ format: 'A4' });
      GBLog.info(`BASIC: Table PDF generated at ${url} .`);
    }

    await browser.close();
    return [url, localName];
  }

  public async asPDF ({ data, filename }) {
    let file = await this.renderTable(data, true, false);
    return file[0];
  }

  public async asImage ({ data, filename }) {
    let file = await this.renderTable(data, false, true);
    return file[0];
  }

  public async executeSQL ({ data, sql, tableName }) {
    let objectMode = false;
    if (Object.keys(data[0])) {
      objectMode = true;
    }

    let first;
    if (!objectMode) {
      first = data.shift();
    }
    data = alasql(sql, [data]);
    if (!objectMode) {
      data.unshift(first);
    }
    return data;
  }

  /**
   * Retrives the content of a given URL.
   */
  public async getFileContents ({ url, headers }) {
    const options = {
      method: 'GET',
      encoding: 'binary',
      headers: headers
    };
    return await fetch(url, options); 
  }

  /**
   * Retrives a random id with a length of five, every time it is called.
   */
  public async getRandomId () {
    const idGeneration = this.dk['idGeneration'];
    if (idGeneration && idGeneration.trim().toLowerCase() === 'number') {
      return GBAdminService.getNumberIdentifier();
    } else {
      return GBAdminService.getRndReadableIdentifier().substr(5);
    }
  }

  /**
   * Retrives stock inforation for a given symbol.
   */
  public async getStock ({ symbol }) {
    var options = {
      uri: `http://live-nse.herokuapp.com/?symbol=${symbol}`
    };

    let data = await request.get(options);
    return data;
  }

  /**
   * Holds script execution for the number of seconds specified.
   *
   * @example WAIT 5 ' This will wait five seconds.
   *
   */
  public async wait ({ seconds }) {
    // tslint:disable-next-line no-string-based-set-timeout
    GBLog.info(`BASIC: WAIT for ${seconds} second(s).`);
    const timeout = async (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
    await timeout(seconds * 1000);
  }

  /**
   * Sends a text message to the mobile number specified.
   *
   * @example TALK TO "+199988887777", "Message text here"
   *
   */
  public async talkTo ({ mobile, message }) {
    GBLog.info(`BASIC: Talking '${message}' to a specific user (${mobile}) (TALK TO). `);
    await this.min.conversationalService.sendMarkdownToMobile(this.min, null, mobile, message);
  }

  /**
   * Sends a SMS message to the mobile number specified.
   *
   * @example SEND SMS TO "+199988887777", "Message text here"
   *
   */
  public async sendSmsTo ({ mobile, message }) {
    GBLog.info(`BASIC: SEND SMS TO '${mobile}', message '${message}'.`);
    await this.min.conversationalService.sendSms(this.min, mobile, message);
  }

  /**
   * 1. Defines a cell value in the tabular file.
   * 2. Defines an element text on HTML page.
   *
   * @example SET "file.xlsx", "A2", 4500
   *
   * @example SET page, "elementHTMLSelector", "text"
   *
   */
  public async set ({ file, address, value }): Promise<any> {
    // Handles calls for HTML stuff

    if (file._javascriptEnabled) {
      const page = file;
      GBLog.info(`BASIC: Web automation setting ${page}' to '${value}' (SET). `);
      await this.wa.setElementText({ page, selector: address, text: value });

      return;
    }

    // Handles calls for BASIC persistence on sheet files.

    GBLog.info(`BASIC: Defining '${address}' in '${file}' to '${value}' (SET). `);

    let { baseUrl, client } = await GBDeployer.internalGetDriveClient(this.min);

    const botId = this.min.instance.botId;
    const path = `/${botId}.gbai/${botId}.gbdata`;

    address = address.indexOf(':') !== -1 ? address : address + ':' + address;

    let document = await this.internalGetDocument(client, baseUrl, path, file);

    let body = { values: [[]] };
    body.values[0][0] = value;

    let sheets = await client.api(`${baseUrl}/drive/items/${document.id}/workbook/worksheets`).get();

    await client
      .api(
        `${baseUrl}/drive/items/${document.id}/workbook/worksheets('${
          sheets.value[0].name
        }')/range(address='${address}')`
      )
      .patch(body);
  }

  /**
   * Retrives a document from the drive, given a path and filename.
   */
  private async internalGetDocument (client: any, baseUrl: any, path: string, file: string) {
    let res = await client.api(`${baseUrl}/drive/root:${path}:/children`).get();

    let documents = res.value.filter(m => {
      return m.name.toLowerCase() === file.toLowerCase();
    });

    if (!documents || documents.length === 0) {
      throw `File '${file}' specified on GBasic command not found. Check the .gbdata or the .gbdialog associated.`;
    }

    return documents[0];
  }

  /**
   * Saves the content of variable into the file in .gbdata default folder.
   *
   * @exaple SAVE variable as "my.txt"
   *
   */
  public async saveFile ({ file, data }): Promise<any> {
    GBLog.info(`BASIC: Saving '${file}' (SAVE file).`);
    let { baseUrl, client } = await GBDeployer.internalGetDriveClient(this.min);
    const botId = this.min.instance.botId;
    const path = `/${botId}.gbai/${botId}.gbdata`;

    // Checks if it is a GB FILE object.

    if (data.data && data.filename) {
      data = data.data;
    }

    try {
      await client.api(`${baseUrl}/drive/root:/${path}/${file}:/content`).put(data);
    } catch (error) {
      if (error.code === 'itemNotFound') {
        GBLog.info(`BASIC: CONVERT source file not found: ${file}.`);
      } else if (error.code === 'nameAlreadyExists') {
        GBLog.info(`BASIC: CONVERT destination file already exists: ${file}.`);
      }
      throw error;
    }
  }

  /**
   * Saves the content of several variables to a new row in a tabular file.
   *
   * @exaple SAVE "customers.xlsx", name, email, phone, address, city, state, country
   *
   */
  public async save ({ args }): Promise<any> {
    const file = args[0];
    args.shift();
    GBLog.info(`BASIC: Saving '${file}' (SAVE). Args: ${args.join(',')}.`);
    let { baseUrl, client } = await GBDeployer.internalGetDriveClient(this.min);
    const botId = this.min.instance.botId;
    const path = `/${botId}.gbai/${botId}.gbdata`;

    let document = await this.internalGetDocument(client, baseUrl, path, file);
    let sheets = await client.api(`${baseUrl}/drive/items/${document.id}/workbook/worksheets`).get();

    await client
      .api(
        `${baseUrl}/drive/items/${document.id}/workbook/worksheets('${
          sheets.value[0].name
        }')/range(address='A2:DX2')/insert`
      )
      .post({});

    if (args.length > 128) {
      throw `File '${file}' has a SAVE call with more than 128 arguments. Check the .gbdialog associated.`;
    }

    let body = { values: [[]] };

    const address = `A2:${this.numberToLetters(args.length - 1)}2`;
    for (let index = 0; index < args.length; index++) {
      let value = args[index];
      if (value && this.isValidDate(value)) {
        value = `'${value}`;
      }
      body.values[0][index] = value;
    }

    await client
      .api(
        `${baseUrl}/drive/items/${document.id}/workbook/worksheets('${
          sheets.value[0].name
        }')/range(address='${address}')`
      )
      .patch(body);
  }

  /**
   * Retrives the content of a cell in a tabular file.
   *
   * @example value = GET "file.xlsx", "A2"
   *
   */
  public async get ({ file, addressOrHeaders, httpUsername, httpPs, qs, streaming }): Promise<any> {
    if (file.startsWith('http')) {
      return await this.getByHttp({
        url: file,
        headers: addressOrHeaders,
        username: httpUsername,
        ps: httpPs,
        qs,
        streaming
      });
    } else {
      GBLog.info(`BASIC: GET '${addressOrHeaders}' in '${file}'.`);
      let { baseUrl, client } = await GBDeployer.internalGetDriveClient(this.min);
      const botId = this.min.instance.botId;
      const path = `/${botId}.gbai/${botId}.gbdata`;

      let document = await this.internalGetDocument(client, baseUrl, path, file);

      // Creates workbook session that will be discarded.

      let sheets = await client.api(`${baseUrl}/drive/items/${document.id}/workbook/worksheets`).get();

      let results = await client
        .api(
          `${baseUrl}/drive/items/${document.id}/workbook/worksheets('${
            sheets.value[0].name
          }')/range(address='${addressOrHeaders}')`
        )
        .get();

      let val = results.text[0][0];
      GBLog.info(`BASIC: Getting '${file}' (GET). Value= ${val}.`);
      return val;
    }
  }

  public isValidDate ({ dt }) {
    const contentLocale = this.min.core.getParam<string>(
      this.min.instance,
      'Default Content Language',
      GBConfigService.get('DEFAULT_CONTENT_LANGUAGE')
    );

    let date = SystemKeywords.getDateFromLocaleString(dt, contentLocale);
    if (!date) {
      return false;
    }

    if (!(date instanceof Date)) {
      date = new Date(date);
    }

    return !isNaN(date.valueOf());
  }

  public isValidNumber ({ number }) {
    if (number === '') {
      return false;
    }
    return !isNaN(number);
  }

  public isValidHour ({ value }) {
    return /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/.test(value);
  }

  /**
   * Finds a value or multi-value results in a tabular file.
   *
   * @example
   *
   *  rows = FIND "file.xlsx", "A2=active", "A2 < 12/06/2010 15:00"
   *  i = 1
   *  do while i < ubound(row)
   *    row = rows[i]
   *    send sms to "+" + row.mobile, "Hello " + row.name + "! "
   *  loop
   * @see NPM package data-forge
   *
   * // TODO: https://www.npmjs.com/package/parse-markdown-table
   */
  public async find ({ args }): Promise<any> {
    const file = args[0];
    args.shift();
    GBLog.info(`BASIC: FIND running on ${file} and args: ${JSON.stringify(args)}...`);

    const botId = this.min.instance.botId;
    const path = `/${botId}.gbai/${botId}.gbdata`;

    // MAX LINES property.

    let maxLines;
    if (this.dk.user && this.dk.user.basicOptions && this.dk.user.basicOptions.maxLines) {
      if (this.dk.user.basicOptions.maxLines.toString().toLowerCase() !== 'default') {
        maxLines = Number.parseInt(this.dk.user.basicOptions.maxLines).valueOf();
      }
    } else {
      maxLines = this.dk.maxLines;
    }

    // Choose data sources based on file type (HTML Table, data variable or sheet file)

    let results;
    let header, rows;

    if (file['$eval']) {
      const container = file['frame'] ? file['frame'] : file['_page'];
      const originalSelector = file['originalSelector'];

      // Transforms table

      const resultH = await container.evaluate(originalSelector => {
        const rows = document.querySelectorAll(`${originalSelector} tr`);
        return Array.from(rows, row => {
          const columns = row.querySelectorAll('th');
          return Array.from(columns, column => column.innerText);
        });
      }, originalSelector);

      const result = await container.evaluate(originalSelector => {
        const rows = document.querySelectorAll(`${originalSelector} tr`);
        return Array.from(rows, row => {
          const columns = row.querySelectorAll('td');
          return Array.from(columns, column => column.innerText);
        });
      }, originalSelector);

      header = [];
      for (let i = 0; i < resultH[0].length; i++) {
        header[i] = resultH[0][i];
      }

      rows = [];
      rows[0] = header;
      for (let i = 1; i < result.length; i++) {
        rows[i] = result[i];
      }
    } else if (file['cTag']) {
      const gbaiName = `${this.min.botId}.gbai`;
      const localName = Path.join('work', gbaiName, 'cache', `csv${GBAdminService.getRndReadableIdentifier()}.csv`);
      const url = file['@microsoft.graph.downloadUrl'];
      const response = await request({ uri: url, encoding: null });
      Fs.writeFileSync(localName, response, { encoding: null });

      var workbook = new Excel.Workbook();
      const worksheet = await workbook.csv.readFile(localName);
      header = [];
      rows = [];

      for (let i = 0; i < worksheet.rowCount; i++) {
        const r = worksheet.getRow(i + 1);
        let outRow = [];
        for (let j = 0; j < r.cellCount; j++) {
          outRow.push(r.getCell(j + 1).text);
        }

        if (i == 0) {
          header = outRow;
        } else {
          rows.push(outRow);
        }
      }
    } else {
      let { baseUrl, client } = await GBDeployer.internalGetDriveClient(this.min);

      let document;
      document = await this.internalGetDocument(client, baseUrl, path, file);

      // Creates workbook session that will be discarded.

      let sheets = await client.api(`${baseUrl}/drive/items/${document.id}/workbook/worksheets`).get();

      results = await client
        .api(
          `${baseUrl}/drive/items/${document.id}/workbook/worksheets('${
            sheets.value[0].name
          }')/range(address='A1:CZ${maxLines}')`
        )
        .get();

      header = results.text[0];
      rows = results.text;
    }

    let getFilter = async text => {
      let filter;
      const operators = [/\<\=/, /\>\=/, /\</, /\>/, /\bnot in\b/, /\bin\b/, /\=/];
      let done = false;
      await CollectionUtil.asyncForEach(operators, async op => {
        var re = new RegExp(op, 'gi');
        const parts = text.split(re);

        if (parts.length === 2 && !done) {
          filter = {
            columnName: parts[0].trim(),
            operator: op
              .toString()
              .replace(/\\b/g, '')
              .replace(/\//g, '')
              .replace(/\\/g, '')
              .replace(/\b/g, ''),
            value: parts[1].trim()
          };

          // Swaps values and names in case of IN operators.

          if (filter.operator === 'not in' || filter.operator === 'in') {
            const columnName = filter.columnName;
            filter.columnName = filter.value;
            filter.value = columnName;
          }

          done = true;
        }
      });

      return filter;
    };

    const contentLocale = this.min.core.getParam<string>(
      this.min.instance,
      'Default Content Language',
      GBConfigService.get('DEFAULT_CONTENT_LANGUAGE')
    );

    // Increments columnIndex by looping until find a column match.

    const filters = [];

    await CollectionUtil.asyncForEach(args, async arg => {
      const filter = await getFilter(arg);
      if (!filter) {
        throw new Error(`BASIC: FIND filter has an error: ${arg} check this and publish .gbdialog again.`);
      }

      let columnIndex = 0;
      for (; columnIndex < header.length; columnIndex++) {
        if (header[columnIndex].toLowerCase() === filter.columnName.toLowerCase()) {
          break;
        }
      }
      filter.columnIndex = columnIndex;

      if (this.isValidHour(filter.value)) {
        filter.dataType = 'hourInterval';
      } else if (this.isValidDate(filter.value)) {
        filter.value = SystemKeywords.getDateFromLocaleString(filter.value, contentLocale);
        filter.dataType = 'date';
      } else if (this.isValidNumber(filter.value)) {
        filter.value = Number.parseInt(filter.value);
        filter.dataType = 'number';
      } else {
        filter.value = filter.value;
        filter.dataType = 'string';
      }
      filters.push(filter);
    });

    // As BASIC uses arrays starting with 1 (one) as index,
    // a ghost element is added at 0 (zero) position.

    let table = [];
    table.push({ gbarray: '0' });
    let foundIndex = 1;

    // Fills the row variable.

    let rowCount = 0;
    for (; foundIndex < rows.length; foundIndex++) {
      let filterAcceptCount = 0;
      await CollectionUtil.asyncForEach(filters, async filter => {
        let result = rows[foundIndex][filter.columnIndex];
        let wholeWord = true;
        if (this.dk.user && this.dk.user.basicOptions && this.dk.user.basicOptions.wholeWord) {
          wholeWord = this.dk.user.basicOptions.wholeWord;
        }

        switch (filter.dataType) {
          case 'string':
            switch (filter.operator) {
              case '=':
                if (wholeWord) {
                  if (result && result.toLowerCase().trim() === filter.value.toLowerCase().trim()) {
                    filterAcceptCount++;
                  }
                } else {
                  if (
                    result &&
                    result
                      .toLowerCase()
                      .trim()
                      .indexOf(filter.value.toLowerCase().trim()) > -1
                  ) {
                    filterAcceptCount++;
                  }
                }
                break;
              case 'not in':
                if (wholeWord) {
                  if (result && result.toLowerCase().trim() !== filter.value.toLowerCase().trim()) {
                    filterAcceptCount++;
                  }
                } else {
                  if (
                    result &&
                    result
                      .toLowerCase()
                      .trim()
                      .indexOf(filter.value.toLowerCase().trim()) === -1
                  ) {
                    filterAcceptCount++;
                  }
                }
                break;
              case 'in':
                if (wholeWord) {
                  if (result && result.toLowerCase().trim() === filter.value.toLowerCase().trim()) {
                    filterAcceptCount++;
                  }
                } else {
                  if (
                    result &&
                    result
                      .toLowerCase()
                      .trim()
                      .indexOf(filter.value.toLowerCase().trim()) > -1
                  ) {
                    filterAcceptCount++;
                  }
                }
                break;
            }
            break;
          case 'number':
            switch (filter.operator) {
              case '=':
                if (Number.parseInt(result) === filter.value) {
                  filterAcceptCount++;
                }
                break;
            }
            break;

          case 'hourInterval':
            switch (filter.operator) {
              case '=':
                if (result && result.toLowerCase().trim() === filter.value.toLowerCase().trim()) {
                  filterAcceptCount++;
                }
                break;
              case 'in':
                const e = result.split(';');
                const hr = Number.parseInt(filter.value.split(':')[0]);
                let lastHour = Number.parseInt(e[0]);
                let found = false;
                await CollectionUtil.asyncForEach(e, async hour => {
                  if (!found && lastHour <= hr && hr <= hour) {
                    filterAcceptCount++;
                    found = true;
                  }
                  lastHour = hour;
                });
                break;
            }
            break;

          case 'date':
            if (result.charAt(0) === "'") {
              result = result.substr(1);
            }
            const resultDate = SystemKeywords.getDateFromLocaleString(result, contentLocale);
            if (filter.value['dateOnly']) {
              resultDate.setHours(0, 0, 0, 0);
            }
            if (resultDate) {
              switch (filter.operator) {
                case '=':
                  if (resultDate.getTime() == filter.value.getTime()) filterAcceptCount++;
                  break;
                case '<':
                  if (resultDate.getTime() < filter.value.getTime()) filterAcceptCount++;
                  break;
                case '>':
                  if (resultDate.getTime() > filter.value.getTime()) filterAcceptCount++;
                  break;
                case '<=':
                  if (resultDate.getTime() <= filter.value.getTime()) filterAcceptCount++;
                  break;
                case '>=':
                  if (resultDate.getTime() >= filter.value.getTime()) filterAcceptCount++;
                  break;
              }
              break;
            }
        }
      });

      if (filterAcceptCount === filters.length) {
        rowCount++;
        let row = {};
        const xlRow = rows[foundIndex];
        for (let colIndex = 0; colIndex < xlRow.length; colIndex++) {
          const propertyName = header[colIndex];
          let value = xlRow[colIndex];
          if (value && value.charAt(0) === "'") {
            if (this.isValidDate(value.substr(1))) {
              value = value.substr(1);
            }
          }
          row[propertyName] = value;
        }
        row['ordinal'] = rowCount;
        row['line'] = foundIndex + 1;
        table.push(row);
      }
    }

    if (table.length === 1) {
      GBLog.info(`BASIC: FIND returned no results (zero rows).`);
      return null;
    } else if (table.length === 2) {
      GBLog.info(`BASIC: FIND returned single result: ${table[0]}.`);
      return table[1];
    } else {
      GBLog.info(`BASIC: FIND returned multiple results (Count): ${table.length - 1}.`);
      return table;
    }
  }

  public static getDateFromLocaleString (date: any, contentLocale: any) {
    let ret = null;
    let parts = /^([0-3]?[0-9]).([0-3]?[0-9]).((?:[0-9]{2})?[0-9]{2})\s*(10|11|12|0?[1-9]):([0-5][0-9])/gi.exec(date);
    if (parts && parts[5]) {
      switch (contentLocale) {
        case 'pt':
          ret = new Date(
            Number.parseInt(parts[3]),
            Number.parseInt(parts[2]) - 1,
            Number.parseInt(parts[1]),
            Number.parseInt(parts[4]),
            Number.parseInt(parts[5]),
            0,
            0
          );
          break;
        case 'en':
          ret = new Date(
            Number.parseInt(parts[3]),
            Number.parseInt(parts[1]) - 1,
            Number.parseInt(parts[2]),
            Number.parseInt(parts[4]),
            Number.parseInt(parts[5]),
            0,
            0
          );
          break;
      }

      ret['dateOnly'] = false;
    }

    parts = /^([0-3]?[0-9]).([0-3]?[0-9]).((?:[0-9]{2})?[0-9]{2})$/gi.exec(date);
    if (parts && parts[3]) {
      switch (contentLocale) {
        case 'pt':
          ret = new Date(
            Number.parseInt(parts[3]),
            Number.parseInt(parts[2]) - 1,
            Number.parseInt(parts[1]),
            0,
            0,
            0,
            0
          );
          break;
        case 'en':
          ret = new Date(
            Number.parseInt(parts[3]),
            Number.parseInt(parts[1]) - 1,
            Number.parseInt(parts[2]),
            0,
            0,
            0,
            0
          );
          break;
      }

      ret['dateOnly'] = true;
    }
    return ret;
  }

  /**
   * Creates a folder in the bot instance drive.
   *
   * @example folder = CREATE FOLDER "notes\01"
   *
   */
  public async createFolder ({ name }) {
    let { baseUrl, client } = await GBDeployer.internalGetDriveClient(this.min);
    const botId = this.min.instance.botId;
    let path = `/${botId}.gbai/${botId}.gbdrive`;

    // Extracts each part of path to call create folder to each
    // one of them.

    name = name.replace(/\\/gi, '/');
    const parts = name.split('/');
    let lastFolder = null;

    // Creates each subfolder.

    await CollectionUtil.asyncForEach(parts, async item => {
      // Calls drive API.

      const body = {
        name: item,
        folder: {},
        '@microsoft.graph.conflictBehavior': 'fail'
      };

      try {
        lastFolder = await client.api(`${baseUrl}/drive/root:/${path}:/children`).post(body);
      } catch (error) {
        if (error.code !== 'nameAlreadyExists') {
          throw error;
        } else {
          lastFolder = await client.api(`${baseUrl}/drive/root:/${urlJoin(path, item)}`).get();
        }
      }

      // Increments path to the next child be created.

      path = urlJoin(path, item);
    });
    return lastFolder;
  }

  /**
   * Shares a folder from the drive to a e-mail recipient.
   *
   * @example
   *
   * folder = CREATE FOLDER "notes\10"
   * SHARE FOLDER folder, "nome@domain.com", "E-mail message"
   *
   */
  public async shareFolder ({ folder, email, message }) {
    let { baseUrl, client } = await GBDeployer.internalGetDriveClient(this.min);
    const root = urlJoin(`/${this.min.botId}.gbai/${this.min.botId}.gbdrive`, folder);

    const src = await client.api(`${baseUrl}/drive/root:/${root}`).get();

    const driveId = src.parentReference.driveId;
    const itemId = src.id;
    const body = {
      recipients: [{ email: email }],
      message: message,
      requireSignIn: true,
      sendInvitation: true,
      roles: ['write']
    };

    await client.api(`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/invite`).post(body);
  }

  /**
   * Copies a drive file from a place to another .
   *
   * @example
   *
   * COPY "template.xlsx", "reports\" + customerName + "\final.xlsx"
   *
   */
  public async copyFile ({ src, dest }) {
    GBLog.info(`BASIC: BEGINING COPY '${src}' to '${dest}'`);
    let { baseUrl, client } = await GBDeployer.internalGetDriveClient(this.min);
    const botId = this.min.instance.botId;

    // Normalizes all slashes.

    src = src.replace(/\\/gi, '/');
    dest = dest.replace(/\\/gi, '/');

    // Determines full path at source and destination.

    const root = urlJoin(`/${botId}.gbai/${botId}.gbdata`);
    const srcPath = urlJoin(root, src);
    const dstPath = urlJoin(`/${botId}.gbai/${botId}.gbdata`, dest);

    // Checks if the destination contains subfolders that
    // need to be created.

    let folder;
    if (dest.indexOf('/') !== -1) {
      const pathOnly = Path.dirname(dest);
      folder = await this.createFolder({ name: pathOnly });
    } else {
      folder = await client.api(`${baseUrl}/drive/root:/${root}`).get();
    }

    // Performs the copy operation getting a reference
    // to the source and calling /copy on drive API.

    try {
      const srcFile = await client.api(`${baseUrl}/drive/root:/${srcPath}`).get();
      const destFile = {
        parentReference: { driveId: folder.parentReference.driveId, id: folder.id },
        name: `${Path.basename(dest)}`
      };

      return await client.api(`${baseUrl}/drive/items/${srcFile.id}/copy`).post(destFile);
    } catch (error) {
      if (error.code === 'itemNotFound') {
        GBLog.info(`BASIC: COPY source file not found: ${srcPath}.`);
      } else if (error.code === 'nameAlreadyExists') {
        GBLog.info(`BASIC: COPY destination file already exists: ${dstPath}.`);
      }
      throw error;
    }
    GBLog.info(`BASIC: FINISHED COPY '${src}' to '${dest}'`);
  }

  /**
   * Converts a drive file from a place to another .
   *
   * Supported sources csv, doc, docx, odp, ods, odt, pot, potm, potx, pps,
   * ppsx, ppsxm, ppt, pptm, pptx, rtf, xls, xlsx
   *
   * @example
   *
   * CONVERT "customers.xlsx" TO "reports\" + today + ".pdf"
   *
   */
  public async convert ({ src, dest }) {
    GBLog.info(`BASIC: CONVERT '${src}' to '${dest}'`);
    let { baseUrl, client } = await GBDeployer.internalGetDriveClient(this.min);
    const botId = this.min.instance.botId;

    // Normalizes all slashes.

    src = src.replace(/\\/gi, '/');
    dest = dest.replace(/\\/gi, '/');

    // Determines full path at source and destination.

    const root = urlJoin(`/${botId}.gbai/${botId}.gbdata`);
    const srcPath = urlJoin(root, src);
    const dstPath = urlJoin(`/${botId}.gbai/${botId}.gbdata`, dest);

    // Checks if the destination contains subfolders that
    // need to be created.

    let folder;
    if (dest.indexOf('/') !== -1) {
      const pathOnly = Path.dirname(dest);
      folder = await this.createFolder({ name: pathOnly });
    } else {
      folder = await client.api(`${baseUrl}/drive/root:/${root}`).get();
    }

    // Performs the conversion operation getting a reference
    // to the source and calling /content on drive API.

    try {
      const res = await client.api(`${baseUrl}/drive/root:/${srcPath}:/content?format=pdf`).get();

      const streamToString = stream => {
        const chunks = [];
        return new Promise((resolve, reject) => {
          stream.on('data', chunk => chunks.push(chunk));
          stream.on('error', reject);
          stream.on('end', () => resolve(Buffer.concat(chunks)));
        });
      };

      const result = await streamToString(res);

      await client.api(`${baseUrl}/drive/root:/${dstPath}:/content`).put(result);
    } catch (error) {
      if (error.code === 'itemNotFound') {
        GBLog.info(`BASIC: CONVERT source file not found: ${srcPath}.`);
      } else if (error.code === 'nameAlreadyExists') {
        GBLog.info(`BASIC: CONVERT destination file already exists: ${dstPath}.`);
      }
      throw error;
    }
  }

  /**
   * Generate a secure and unique password.
   *
   * @example pass = PASSWORD
   *
   */
  public generatePassword () {
    return GBAdminService.getRndPassword();
  }

  /**
   * Calls any REST API by using GET HTTP method.
   *
   * @example user = get "http://server/users/1"
   *
   */
  public async getByHttp ({ url, headers, username, ps, qs, streaming }) {
    let options = { url: url };
    if (headers) {
      options['headers'] = headers;
    }
    if (username) {
      options['auth'] = {
        user: username,
        pass: ps
      };
    }
    if (qs) {
      options['qs'] = qs;
    }
    if (streaming) {
      options['responseType'] = 'stream';
      options['encoding'] = null;
    }
    let result = await request.get(options);

    try {
      return JSON.parse(result);
    } catch (error) {
      GBLog.info(`[GET]: OK.`);

      return result;
    }
  }

  /**
   * Calls any REST API by using POST HTTP method.
   *
   * @example
   *
   * user = put "http://server/path", "data"
   * talk "The updated user area is" + user.area
   *
   */
  public async putByHttp ({ url, data, headers }) {
    const options = {
      uri: url,
      json: data,
      headers: headers
    };

    let result = await request.put(options);
    GBLog.info(`[PUT]: ${url} (${data}): ${result}`);
    return typeof result === 'object' ? result : JSON.parse(result);
  }

  /**
   * Calls any REST API by using POST HTTP method.
   *
   * @example
   *
   * user = post "http://server/path", "data"
   * talk "The updated user area is" + user.area
   *
   */
  public async postByHttp ({ url, data, headers }) {
    const options = {
      uri: url,
      json: data,
      headers: headers
    };

    let result = await request.post(options);
    GBLog.info(`[POST]: ${url} (${data}): ${result}`);

    return result ? (typeof result === 'object' ? result : JSON.parse(result)) : true;
  }

  public async numberOnly (text: string) {
    return text.replace(/\D/gi, '');
  }

  /**
   *
   * Fills a .docx or .pptx with template data.
   *
   * doc = FILL "templates/template.docx", data
   *
   */
  public async fill ({ templateName, data }) {
    const botId = this.min.instance.botId;
    const gbaiName = `${botId}.gbai`;
    const path = `/${botId}.gbai/${botId}.gbdata`;

    // Downloads template from .gbdrive.

    let { baseUrl, client } = await GBDeployer.internalGetDriveClient(this.min);
    let template = await this.internalGetDocument(client, baseUrl, path, templateName);
    const url = template['@microsoft.graph.downloadUrl'];
    const localName = Path.join('work', gbaiName, 'cache', ``);
    const response = await request({ uri: url, encoding: null });
    Fs.writeFileSync(localName, response, { encoding: null });

    // Loads the file as binary content.

    const content = Fs.readFileSync(localName, 'binary');
    const zip = new PizZip(content);
    const doc = new Docxtemplater(zip, { paragraphLoop: true, linebreaks: true });
    if (localName.endsWith('.pptx')) {
      doc.attachModule(pptxTemplaterModule);
    }

    // Renders the document (Replace {first_name} by John, {last_name} by Doe, ...)

    doc.render(data);

    // Returns the buffer to be used with SAVE AS for example.

    const buf = doc.getZip().generate({ type: 'nodebuffer', compression: 'DEFLATE' });

    return buf;
  }

  public screenCapture () {
    // scrcpy
    // function captureImage({ x, y, w, h }) {
    //   const pic = robot.screen.capture(x, y, w, h)
    //   const width = pic.byteWidth / pic.bytesPerPixel // pic.width is sometimes wrong!
    //   const height = pic.height
    //   const image = new Jimp(width, height)
    //   let red, green, blue
    //   pic.image.forEach((byte, i) => {
    //     switch (i % 4) {
    //       case 0: return blue = byte
    //       case 1: return green = byte
    //       case 2: return red = byte
    //       case 3:
    //         image.bitmap.data[i - 3] = red
    //         image.bitmap.data[i - 2] = green
    //         image.bitmap.data[i - 1] = blue
    //         image.bitmap.data[i] = 255
    //     }
    //   })
    //   return image
    // }
    // let file = 'out.png';
    // captureImage({ x: 60, y: 263, w: 250, h: 83 }).write(file)
    // const config = {
    //   lang: "eng",
    //   oem: 1,
    //   psm: 3,
    // }
    // tesseract.recognize(file, config).then(value => {
    //   console.log(value);
    // });
  }

  private numberToLetters (num) {
    let letters = '';
    while (num >= 0) {
      letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[num % 26] + letters;
      num = Math.floor(num / 26) - 1;
    }
    return letters;
  }

  /**
   * Merges a multi-value with a tabular file using BY field as key.
   *
   * @example
   *
   *  data = FIND first.xlsx
   *  MERGE "second.xlsx" WITH data BY customer_id
   *
   */
  public async merge ({ file, data, key1, key2 }): Promise<any> {
    GBLog.info(`BASIC: MERGE running on ${file} and key1: ${key1}, key2: ${key2}...`);

    const botId = this.min.instance.botId;
    const path = `/${botId}.gbai/${botId}.gbdata`;

    // MAX LINES property.

    let maxLines = 1000;
    if (this.dk.user && this.dk.user.basicOptions && this.dk.user.basicOptions.maxLines) {
      if (this.dk.user.basicOptions.maxLines.toString().toLowerCase() !== 'default') {
        maxLines = Number.parseInt(this.dk.user.basicOptions.maxLines).valueOf();
      }
    }

    // Choose data sources based on file type (HTML Table, data variable or sheet file)

    let results;
    let header, rows;
    let { baseUrl, client } = await GBDeployer.internalGetDriveClient(this.min);

    let document;
    document = await this.internalGetDocument(client, baseUrl, path, file);

    // Creates workbook session that will be discarded.

    let sheets = await client.api(`${baseUrl}/drive/items/${document.id}/workbook/worksheets`).get();

    results = await client
      .api(
        `${baseUrl}/drive/items/${document.id}/workbook/worksheets('${
          sheets.value[0].name
        }')/range(address='A1:CZ${maxLines}')`
      )
      .get();

    header = results.text[0];
    rows = results.text;

    // As BASIC uses arrays starting with 1 (one) as index,
    // a ghost element is added at 0 (zero) position.

    let table = [];
    table.push({ gbarray: '0' });
    let foundIndex = 1;

    // Fills the row variable.

    for (; foundIndex < rows.length; foundIndex++) {
      let row = {};
      const xlRow = rows[foundIndex];
      for (let colIndex = 0; colIndex < xlRow.length; colIndex++) {
        const propertyName = header[colIndex];
        let value = xlRow[colIndex];
        if (value && value.charAt(0) === "'") {
          if (this.isValidDate(value.substr(1))) {
            value = value.substr(1);
          }
        }
        row[propertyName] = value;
      }
      row['line'] = foundIndex + 1;
      table.push(row);
    }

    let key1Index, key2Index;

    if (key1) {
      key1Index = _.invertBy(table, key1);
    }

    if (key2) {
      key2Index = _.invertBy(table, key2);
    }

    let merges = 0,
      adds = 0;

    // Scans all items in incoming data.

    for (let i = 1; i < data.length; i++) {
      // Scans all sheet lines and compare keys.

      const row = data[i];
      let found;
      if (key1Index) {
        const key1Value = row[key1];
        const foundRow = key1Index[key1Value];
        if (foundRow) {
          found = table[foundRow[0]];
        }
      }

      if (found) {
        let keys = Object.keys(row);
        for (let j = 0; j < keys.length; j++) {
          const columnName = header[j];
          const value = row[keys[j]];
          const cell = `${this.numberToLetters(j)}${i + 1}`;
          const address = `${cell}:${cell}`;

          if (value !== found[columnName]) {
            await this.set({ file, address, value });
            merges++;
          }
        }
      } else {
        let args = [file];
        let keys = Object.keys(row);
        for (let j = 0; j < keys.length; j++) {
          args.push(row[keys[j]]);
        }

        await this.save({ args });
        adds++;
      }
    }

    if (table.length === 1) {
      GBLog.info(`BASIC: MERGE ran but updated zero rows.`);
      return null;
    } else {
      GBLog.info(`BASIC: MERGE updated (merges:${merges}, additions:${adds}.`);
      return table;
    }
  }

  public async tweet ({ text }) {
    const consumer_key = this.min.core.getParam(this.min.instance, 'Twitter Consumer Key', null);
    const consumer_secret = this.min.core.getParam(this.min.instance, 'Twitter Consumer Key Secret', null);
    const access_token_key = this.min.core.getParam(this.min.instance, 'Twitter Access Token', null);
    const access_token_secret = this.min.core.getParam(this.min.instance, 'Twitter Access Token Secret', null);

    if (!consumer_key || !consumer_secret || !access_token_key || !access_token_secret) {
      GBLog.info('Twitter not configured in .gbot.');
    }

    const client = new TwitterApi({
      appKey: consumer_key,
      appSecret: consumer_secret,
      accessToken: access_token_key,
      accessSecret: access_token_secret
    });

    await client.v2.tweet(text);
    GBLog.info(`Twitter Automation: ${text}.`);
  }
}
