/*
 * Copyright (c) 2010, 2023 BSI Business Systems Integration AG
 *
 * This program and the accompanying materials are made
 * available under the terms of the Eclipse Public License 2.0
 * which is available at https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 */
import {arrays, BlobWithName, ClipboardFieldModel, Device, DragAndDropOptions, FatalMessageOptions, InputFieldKeyStrokeContext, keys, KeyStrokeContext, mimeTypes, scout, Session, strings, ValueField} from '../../../index';
import $ from 'jquery';

export class ClipboardField extends ValueField<string> implements ClipboardFieldModel {
  declare model: ClipboardFieldModel;

  allowedMimeTypes: string[];
  maximumSize: number;
  readOnly: boolean;
  protected _fileUploadWaitRetryCountTimeout: number;
  protected _fullSelectionLength: number;

  constructor() {
    super();

    this.allowedMimeTypes = null;
    this.maximumSize = null;
    this._fileUploadWaitRetryCountTimeout = 99;
    this._fullSelectionLength = 0;
  }

  // Keys that don't alter the content of a text field and are therefore always allowed in the clipboard field
  static NON_DESTRUCTIVE_KEYS = [
    // Default form handling
    keys.ESC,
    keys.ENTER,
    keys.TAB,
    // Navigate and mark text
    keys.PAGE_UP,
    keys.PAGE_DOWN,
    keys.END,
    keys.HOME,
    keys.LEFT,
    keys.UP,
    keys.RIGHT,
    keys.DOWN,
    // Browser hotkeys (e.g. developer tools)
    keys.F1,
    keys.F2,
    keys.F3,
    keys.F4,
    keys.F5,
    keys.F6,
    keys.F7,
    keys.F8,
    keys.F9,
    keys.F10,
    keys.F11,
    keys.F12
  ];

  // Keys that always alter the content of a text field, independent from the modifier keys
  static ALWAYS_DESTRUCTIVE_KEYS = [
    keys.BACKSPACE,
    keys.DELETE
  ];

  protected override _createKeyStrokeContext(): KeyStrokeContext {
    return new InputFieldKeyStrokeContext();
  }

  protected override _render() {
    // We don't use makeDiv() here intentionally because the DIV created must
    // not have the 'unselectable' attribute.
    this.addContainer(this.$parent, 'clipboard-field');
    this.addLabel();
    this.addField(this.$parent.makeElement('<div>').addClass('input-field'));
    this.addStatus();

    this.$field
      .disableSpellcheck()
      .attr('contenteditable', 'true')
      .attr('tabindex', '0')
      .on('keydown', this._onKeyDown.bind(this))
      .on('input', this._onInput.bind(this))
      .on('paste', this._onPaste.bind(this))
      .on('copy', this._onCopy.bind(this))
      .on('cut', this._onCopy.bind(this));
  }

  protected override _getDragAndDropHandlerOptions(): DragAndDropOptions {
    let options = super._getDragAndDropHandlerOptions();
    options.allowedTypes = () => this.allowedMimeTypes;
    // use the smaller property (this.maximumSize for backwards compatibility) but ignore null values which would result in a maximum size of zero.
    options.dropMaximumSize = () => Math.min(scout.nvl(this.dropMaximumSize, Number.MAX_VALUE), scout.nvl(this.maximumSize, Number.MAX_VALUE));
    return options;
  }

  protected override _renderDisplayText() {
    let displayText = this.displayText;
    let img: HTMLElement;
    this.$field.children().each((idx, elem) => {
      if (!img && elem.nodeName === 'IMG') {
        img = elem;
      }
    });

    if (strings.hasText(displayText)) {
      this.$field.html(strings.nl2br(displayText, true));
      this._installScrollbars();

      setTimeout(() => {
        this.$field.selectAllText();
        // store length of full selection, in order to determine if the whole text is selected in "onCopy"
        let selection = this._getSelection();
        this._fullSelectionLength = (selection) ? selection.toString().length : 0;
      });
    } else {
      this.$field.empty();
    }
    // restore old img for firefox upload mechanism.
    if (img) {
      this.$field.prepend(img);
    }
  }

  protected _getSelection(): Selection {
    let selection: Selection,
      myWindow = this.$container.window(true);
    if (myWindow.getSelection) {
      selection = myWindow.getSelection();
    } else if (document.getSelection) {
      selection = document.getSelection();
    }
    if (!selection || selection.toString().length === 0) {
      return null;
    }
    return selection;
  }

  protected _onKeyDown(event: JQuery.KeyDownEvent): boolean {
    if (scout.isOneOf(event.which, ClipboardField.ALWAYS_DESTRUCTIVE_KEYS)) {
      return false; // never allowed
    }
    if (event.ctrlKey || event.altKey || event.metaKey || scout.isOneOf(event.which, ClipboardField.NON_DESTRUCTIVE_KEYS)) {
      return; // allow bubble to other event handlers
    }
    // do not allow to enter something manually
    return false;
  }

  protected _onInput(event: JQuery.TriggeredEvent): boolean {
    // if the user somehow managed to fire to input something (e.g. "delete" menu in FF & IE), just reset the value to the previous content
    this._renderDisplayText();
    return false;
  }

  protected _onCopy(event: JQuery.TriggeredEvent): boolean {
    if (Device.get().isIos() && this._onIosCopy(event) === false) {
      return;
    }

    let text: string,
      dataTransfer: DataTransfer,
      myWindow = this.$container.window(true);
    try {
      let originalEvent = event.originalEvent as ClipboardEvent;
      if (originalEvent.clipboardData) {
        dataTransfer = originalEvent.clipboardData;
      } else if (myWindow['clipboardData']) {
        dataTransfer = myWindow['clipboardData'];
      }
    } catch (e) {
      // Because windows forbids concurrent access to the clipboard, a possible exception is thrown on 'myWindow.clipboardData'
      // (see Remarks on https://msdn.microsoft.com/en-us/library/windows/desktop/ms649048(v=vs.85).aspx)
      // Because of this behavior a failed access will just be logged but not presented to the user.
      $.log.error('Error while reading "clipboardData"', e);
    }
    if (!dataTransfer) {
      $.log.error('Unable to access clipboard data.');
      return false;
    }

    // scroll bar must not be in field when copying
    this._uninstallScrollbars();

    let selection = this._getSelection();
    if (!selection) {
      return;
    }

    // if the length of the selection is equals to the length of the (initial) full selection
    // use the internal 'displayText' value because some browsers are collapsing white spaces
    // which lead to problems when coping data form tables with empty cells ("\t\t").
    if (selection.toString().length === this._fullSelectionLength) {
      text = this.displayText;
    } else {
      text = selection.toString();
    }

    try {
      // Chrome, Firefox - causes an exception in IE
      dataTransfer.setData('text/plain', text);
    } catch (e) {
      // IE, see https://www.lucidchart.com/techblog/2014/12/02/definitive-guide-copying-pasting-javascript/
      dataTransfer.setData('Text', text);
    }

    // (re)install scroll bars
    this._installScrollbars();

    return false;
  }

  protected _onIosCopy(event: JQuery.TriggeredEvent): boolean {
    // Setting custom clipboard data is not possible with iOS due to a WebKit bug.
    // The default behavior copies rich text. Since it is not expected to copy the style of the clipboard field, temporarily set color and background-color
    // https://bugs.webkit.org/show_bug.cgi?id=176980
    let oldStyle = this.$field.attr('style');
    this.$field.css({
      'color': '#000',
      'background-color': 'transparent'
    });
    setTimeout(() => {
      this.$field.attrOrRemove('style', oldStyle);
    });
    return false;
  }

  protected _onPaste(event: JQuery.TriggeredEvent): boolean {
    if (this.readOnly) {
      // Prevent pasting in "copy" mode
      return false;
    }

    let startPasteTimestamp = Date.now();
    let dataTransfer: DataTransfer,
      myWindow = this.$container.window(true);
    this.$field.selectAllText();
    let originalEvent = event.originalEvent as ClipboardEvent;
    if (originalEvent.clipboardData) {
      dataTransfer = originalEvent.clipboardData;
    } else if (myWindow['clipboardData']) {
      dataTransfer = myWindow['clipboardData'];
    } else {
      // unable to obtain data transfer object
      throw new Error('Unable to access clipboard data.');
    }

    let filesArgument: BlobWithName[] = [], // options to be uploaded, arguments for this.session.uploadFiles
      additionalOptions: Record<string, string> = {},
      additionalOptionsCompatibilityIndex = 0, // counter for additional options
      contentCount = 0;

    // some browsers (e.g. IE) specify text content simply as data of type 'Text', it is not listed in list of types
    let textContent = dataTransfer.getData('Text');
    if (textContent) {
      if (window.Blob) {
        filesArgument.push(new Blob([textContent], {
          type: mimeTypes.TEXT_PLAIN
        }));
        contentCount++;
      } else {
        // compatibility workaround
        additionalOptions['textTransferObject' + additionalOptionsCompatibilityIndex++] = textContent;
        contentCount++;
      }
    }

    if (contentCount === 0 && dataTransfer.items) {
      Array.prototype.forEach.call(dataTransfer.items, (item: DataTransferItem) => {
        if (item.type === mimeTypes.TEXT_PLAIN) {
          item.getAsString(str => {
            filesArgument.push(new Blob([str], {
              type: mimeTypes.TEXT_PLAIN
            }));
            contentCount++;
          });
        } else if (scout.isOneOf(item.type, [mimeTypes.IMAGE_PNG, mimeTypes.IMAGE_JPG, mimeTypes.IMAGE_JPEG, mimeTypes.IMAGE_GIF])) {
          let file: BlobWithName & File = item.getAsFile();
          if (file) {
            // When pasting an image from the clipboard, Chrome and Firefox create a File object with
            // a generic name such as "image.png" or "grafik.png" (hardcoded in Chrome, locale-dependent
            // in FF). It is therefore not possible to distinguish between a real file and a bitmap
            // from the clipboard. The following code measures the time between the start of the paste
            // event and the file's last modified timestamp. If it is "very small", the file is likely
            // a bitmap from the clipboard and not a real file. In that case, add a special "scoutName"
            // attribute to the file object that is then used as a filename in session.uploadFiles().
            let lastModifiedDiff = startPasteTimestamp - file.lastModified;
            if (lastModifiedDiff < 1000) {
              file.scoutName = Session.EMPTY_UPLOAD_FILENAME;
            }
            filesArgument.push(file);
            contentCount++;
          }
        }
      });
      this._cleanupFiles(filesArgument);
    }

    let waitForFileReaderEvents = 0;
    if (contentCount === 0 && dataTransfer.files) {
      Array.prototype.forEach.call(dataTransfer.files, (item: File) => {
        let reader = new FileReader();
        // register functions for file reader
        reader.onload = event => {
          let f: BlobWithName = new Blob([event.target.result], {
            type: item.type
          });
          f.name = item.name;
          filesArgument.push(f);
          waitForFileReaderEvents--;
        };
        reader.onerror = event => {
          waitForFileReaderEvents--;
          $.log.error('Error while reading file ' + item.name + ' / ' + event.target.error.code);
        };
        // start file reader
        waitForFileReaderEvents++;
        contentCount++;
        reader.readAsArrayBuffer(item);
      });
    }

    // upload function needs to be called asynchronously to support real files
    let uploadFunctionTimeoutCount = 0;
    let uploadFunction = () => {
      if (waitForFileReaderEvents !== 0 && uploadFunctionTimeoutCount++ !== this._fileUploadWaitRetryCountTimeout) {
        setTimeout(uploadFunction, 150);
        return;
      }

      if (uploadFunctionTimeoutCount >= this._fileUploadWaitRetryCountTimeout) {
        let boxOptions: FatalMessageOptions = {
          entryPoint: this.$container.entryPoint(),
          header: this.session.text('ui.ClipboardTimeoutTitle'),
          body: this.session.text('ui.ClipboardTimeout'),
          yesButtonText: this.session.text('Ok')
        };

        this.session.showFatalMessage(boxOptions);
        return;
      }

      // upload paste event as files
      if (filesArgument.length > 0 || Object.keys(additionalOptions).length > 0) {
        this.session.uploadFiles(this, filesArgument, additionalOptions, this.maximumSize, this.allowedMimeTypes);
      }
    };

    // upload content function, if content can not be read from event
    // (e.g. "Allow programmatic clipboard access" is disabled in IE)
    let uploadContentFunction = () => {
      // store old inner html (will be replaced)
      this._uninstallScrollbars();
      let oldHtmlContent = this.$field.html();
      this.$field.html('');
      let restoreOldHtmlContent = () => {
        this.$field.html(oldHtmlContent);
        this._installScrollbars();
      };
      setTimeout(() => {
        let imgElementsFound = false;
        this.$field.children().each((idx, elem) => {
          if (elem.nodeName === 'IMG') {
            let srcAttr = $(elem).attr('src');
            let srcDataMatch = /^data:(.*);base64,(.*)/.exec(srcAttr);
            let mimeType = srcDataMatch && srcDataMatch[1];
            if (scout.isOneOf(mimeType, mimeTypes.IMAGE_PNG, mimeTypes.IMAGE_JPG, mimeTypes.IMAGE_JPEG, mimeTypes.IMAGE_GIF)) {
              let encData = window.atob(srcDataMatch[2]); // base64 decode
              let byteNumbers = [];
              for (let i = 0; i < encData.length; i++) {
                byteNumbers[i] = encData.charCodeAt(i);
              }
              let byteArray = new Uint8Array(byteNumbers);
              let f: BlobWithName = new Blob([byteArray], {
                type: mimeType
              });
              f.name = '';
              filesArgument.push(f);
              imgElementsFound = true;
            }
          }
        });
        if (imgElementsFound) {
          restoreOldHtmlContent();
        } else {
          // try to read natively pasted text from field
          let nativePasteContent = this.$field.text();
          if (strings.hasText(nativePasteContent)) {
            this.setDisplayText(nativePasteContent);
            filesArgument.push(new Blob([nativePasteContent], {
              type: mimeTypes.TEXT_PLAIN
            }));
          } else {
            restoreOldHtmlContent();
          }
        }
        uploadFunction();
      }, 0);
    };

    if (contentCount > 0) {
      uploadFunction();

      // do not trigger any other actions
      return false;
    }
    uploadContentFunction();

    // trigger other actions to catch content
    return true;
  }

  /**
   * Safari creates two files when pasting an image from clipboard, one PNG and one JPEG.
   * If that happens, remove the JPEG and only keep the PNG.
   */
  protected _cleanupFiles(files: BlobWithName[]) {
    if (files.length !== 2) {
      return;
    }
    let pngImage: BlobWithName;
    let jpgImage: BlobWithName;
    files.forEach(file => {
      // Check for the scoutName because it will only be set if it is likely a paste from clipboard event
      if (file.name === 'image.png' && file.scoutName) {
        pngImage = file;
      } else if (file.name === 'image.jpeg' && file.scoutName) {
        jpgImage = file;
      }
    });
    if (pngImage && jpgImage) {
      arrays.remove(files, jpgImage);
    }
  }
}
