<script>
import {
  messageSchema,
  buildEditor,
  EditorView,
  MessageMarkdownTransformer,
  MessageMarkdownSerializer,
  EditorState,
  Selection,
} from '@chatwoot/prosemirror-schema';
import {
  suggestionsPlugin,
  triggerCharacters,
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
import { BUS_EVENTS } from 'shared/constants/busEvents';

import TagAgents from '../conversation/TagAgents.vue';
import CannedResponse from '../conversation/CannedResponse.vue';
import VariableList from '../conversation/VariableList.vue';
import {
  appendSignature,
  removeSignature,
  insertAtCursor,
  scrollCursorIntoView,
  findNodeToInsertImage,
  setURLWithQueryAndSize,
} from 'dashboard/helper/editorHelper';

const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB

import {
  hasPressedEnterAndNotCmdOrShift,
  hasPressedCommandAndEnter,
} from 'shared/helpers/KeyboardHelpers';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { useUISettings } from 'dashboard/composables/useUISettings';
import {
  replaceVariablesInMessage,
  createTypingIndicator,
} from '@chatwoot/utils';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
import { useAlert } from 'dashboard/composables';
import {
  MESSAGE_EDITOR_MENU_OPTIONS,
  MESSAGE_EDITOR_IMAGE_RESIZES,
} from 'dashboard/constants/editor';

const createState = (
  content,
  placeholder,
  // eslint-disable-next-line default-param-last
  plugins = [],
  // eslint-disable-next-line default-param-last
  methods = {},
  enabledMenuOptions
) => {
  return EditorState.create({
    doc: new MessageMarkdownTransformer(messageSchema).parse(content),
    plugins: buildEditor({
      schema: messageSchema,
      placeholder,
      methods,
      plugins,
      enabledMenuOptions,
    }),
  });
};

export default {
  name: 'WootMessageEditor',
  components: { TagAgents, CannedResponse, VariableList },
  mixins: [keyboardEventListenerMixins],
  props: {
    value: { type: String, default: '' },
    editorId: { type: String, default: '' },
    placeholder: { type: String, default: '' },
    isPrivate: { type: Boolean, default: false },
    enableSuggestions: { type: Boolean, default: true },
    overrideLineBreaks: { type: Boolean, default: false },
    updateSelectionWith: { type: String, default: '' },
    enableVariables: { type: Boolean, default: false },
    enableCannedResponses: { type: Boolean, default: true },
    variables: { type: Object, default: () => ({}) },
    enabledMenuOptions: { type: Array, default: () => [] },
    signature: { type: String, default: '' },
    // allowSignature is a kill switch, ensuring no signature methods
    // are triggered except when this flag is true
    allowSignature: { type: Boolean, default: false },
    channelType: { type: String, default: '' },
    showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
  },
  setup() {
    const {
      uiSettings,
      isEditorHotKeyEnabled,
      fetchSignatureFlagFromUISettings,
    } = useUISettings();

    return {
      uiSettings,
      isEditorHotKeyEnabled,
      fetchSignatureFlagFromUISettings,
    };
  },
  data() {
    return {
      typingIndicator: createTypingIndicator(
        () => {
          this.$emit('typingOn');
        },
        () => {
          this.$emit('typingOff');
        },
        TYPING_INDICATOR_IDLE_TIME
      ),
      showUserMentions: false,
      showCannedMenu: false,
      showVariables: false,
      mentionSearchKey: '',
      cannedSearchTerm: '',
      variableSearchTerm: '',
      editorView: null,
      range: null,
      state: undefined,
      isImageNodeSelected: false,
      toolbarPosition: { top: 0, left: 0 },
      sizes: MESSAGE_EDITOR_IMAGE_RESIZES,
      selectedImageNode: null,
    };
  },
  computed: {
    editorRoot() {
      return this.$refs.editorRoot;
    },
    contentFromEditor() {
      return MessageMarkdownSerializer.serialize(this.editorView.state.doc);
    },
    shouldShowVariables() {
      return this.enableVariables && this.showVariables && !this.isPrivate;
    },
    shouldShowCannedResponses() {
      return (
        this.enableCannedResponses && this.showCannedMenu && !this.isPrivate
      );
    },
    editorMenuOptions() {
      return this.enabledMenuOptions.length
        ? this.enabledMenuOptions
        : MESSAGE_EDITOR_MENU_OPTIONS;
    },
    plugins() {
      if (!this.enableSuggestions) {
        return [];
      }

      return [
        suggestionsPlugin({
          matcher: triggerCharacters('@'),
          onEnter: args => {
            this.showUserMentions = true;
            this.range = args.range;
            this.editorView = args.view;
            return false;
          },
          onChange: args => {
            this.editorView = args.view;
            this.range = args.range;

            this.mentionSearchKey = args.text.replace('@', '');

            return false;
          },
          onExit: () => {
            this.mentionSearchKey = '';
            this.showUserMentions = false;
            return false;
          },
          onKeyDown: ({ event }) => {
            return event.keyCode === 13 && this.showUserMentions;
          },
        }),
        suggestionsPlugin({
          matcher: triggerCharacters('/'),
          suggestionClass: '',
          onEnter: args => {
            if (this.isPrivate) {
              return false;
            }
            this.showCannedMenu = true;
            this.range = args.range;
            this.editorView = args.view;
            return false;
          },
          onChange: args => {
            this.editorView = args.view;
            this.range = args.range;

            this.cannedSearchTerm = args.text.replace('/', '');
            return false;
          },
          onExit: () => {
            this.cannedSearchTerm = '';
            this.showCannedMenu = false;
            return false;
          },
          onKeyDown: ({ event }) => {
            return event.keyCode === 13 && this.showCannedMenu;
          },
        }),
        suggestionsPlugin({
          matcher: triggerCharacters('{{'),
          suggestionClass: '',
          onEnter: args => {
            if (this.isPrivate) {
              return false;
            }
            this.showVariables = true;
            this.range = args.range;
            this.editorView = args.view;
            return false;
          },
          onChange: args => {
            this.editorView = args.view;
            this.range = args.range;

            this.variableSearchTerm = args.text.replace('{{', '');
            return false;
          },
          onExit: () => {
            this.variableSearchTerm = '';
            this.showVariables = false;
            return false;
          },
          onKeyDown: ({ event }) => {
            return event.keyCode === 13 && this.showVariables;
          },
        }),
      ];
    },
    sendWithSignature() {
      // this is considered the source of truth, we watch this property
      // on change, we toggle the signature in the editor
      if (this.allowSignature && !this.isPrivate && this.channelType) {
        return this.fetchSignatureFlagFromUISettings(this.channelType);
      }

      return false;
    },
  },
  watch: {
    showUserMentions(updatedValue) {
      this.$emit('toggleUserMention', this.isPrivate && updatedValue);
    },
    showCannedMenu(updatedValue) {
      this.$emit('toggleCannedMenu', !this.isPrivate && updatedValue);
    },
    showVariables(updatedValue) {
      this.$emit('toggleVariablesMenu', !this.isPrivate && updatedValue);
    },
    value(newVal = '') {
      if (newVal !== this.contentFromEditor) {
        this.reloadState(newVal);
      }
    },
    editorId() {
      this.showCannedMenu = false;
      this.cannedSearchTerm = '';
      this.reloadState(this.value);
    },
    isPrivate() {
      this.reloadState(this.value);
    },
    updateSelectionWith(newValue, oldValue) {
      if (!this.editorView) {
        return null;
      }
      if (newValue !== oldValue) {
        if (this.updateSelectionWith !== '') {
          const node = this.editorView.state.schema.text(
            this.updateSelectionWith
          );
          const tr = this.editorView.state.tr.replaceSelectionWith(node);
          this.editorView.focus();
          this.state = this.editorView.state.apply(tr);
          this.editorView.updateState(this.state);
          this.emitOnChange();
          this.$emit('clearSelection');
        }
      }
      return null;
    },
    sendWithSignature(newValue) {
      // see if the allowSignature flag is true
      if (this.allowSignature) {
        this.toggleSignatureInEditor(newValue);
      }
    },
  },
  created() {
    this.state = createState(
      this.value,
      this.placeholder,
      this.plugins,
      { onImageUpload: this.openFileBrowser },
      this.editorMenuOptions
    );
  },
  mounted() {
    this.createEditorView();
    this.editorView.updateState(this.state);
    this.focusEditorInputField();

    // BUS Event to insert text or markdown into the editor at the
    // current cursor position.
    // Components using this
    // 1. SearchPopover.vue

    this.$emitter.on(
      BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
      this.insertContentIntoEditor
    );
  },
  beforeDestroy() {
    this.$emitter.off(
      BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
      this.insertContentIntoEditor
    );
  },
  methods: {
    reloadState(content = this.value) {
      this.state = createState(
        content,
        this.placeholder,
        this.plugins,
        { onImageUpload: this.openFileBrowser },
        this.editorMenuOptions
      );
      this.editorView.updateState(this.state);

      this.focusEditor(content);
    },
    focusEditor(content) {
      if (this.isBodyEmpty(content) && this.sendWithSignature) {
        // reload state can be called when switching between conversations, or when drafts is loaded
        // these drafts can also have a signature, so we need to check if the body is empty
        // and handle things accordingly
        this.handleEmptyBodyWithSignature();
      } else {
        // this is in the else block, handleEmptyBodyWithSignature also has a call to the focus method
        // the position is set to start, because the signature is added at the end of the body
        this.focusEditorInputField('end');
      }
    },
    toggleSignatureInEditor(signatureEnabled) {
      // The toggleSignatureInEditor gets the new value from the
      // watcher, this means that if the value is true, the signature
      // is supposed to be added, else we remove it.
      if (signatureEnabled) {
        this.addSignature();
      } else {
        this.removeSignature();
      }
    },
    addSignature() {
      let content = this.value;
      // see if the content is empty, if it is before appending the signature
      // we need to add a paragraph node and move the cursor at the start of the editor
      const contentWasEmpty = this.isBodyEmpty(content);
      content = appendSignature(content, this.signature);
      // need to reload first, ensuring that the editorView is updated
      this.reloadState(content);

      if (contentWasEmpty) {
        this.handleEmptyBodyWithSignature();
      }
    },
    removeSignature() {
      if (!this.signature) return;
      let content = this.value;
      content = removeSignature(content, this.signature);
      // reload the state, ensuring that the editorView is updated
      this.reloadState(content);
    },
    isBodyEmpty(content) {
      // if content is undefined, we assume that the body is empty
      if (!content) return true;

      // if the signature is present, we need to remove it before checking
      // note that we don't update the editorView, so this is safe
      const bodyWithoutSignature = this.signature
        ? removeSignature(content, this.signature)
        : content;

      // trimming should remove all the whitespaces, so we can check the length
      return bodyWithoutSignature.trim().length === 0;
    },
    handleEmptyBodyWithSignature() {
      const { schema, tr } = this.state;

      // create a paragraph node and
      // start a transaction to append it at the end
      const paragraph = schema.nodes.paragraph.create();
      const paragraphTransaction = tr.insert(0, paragraph);
      this.editorView.dispatch(paragraphTransaction);

      // Set the focus at the start of the input field
      this.focusEditorInputField('start');
    },
    createEditorView() {
      this.editorView = new EditorView(this.$refs.editor, {
        state: this.state,
        dispatchTransaction: tx => {
          this.state = this.state.apply(tx);
          this.editorView.updateState(this.state);
          this.emitOnChange();
        },
        handleDOMEvents: {
          keyup: () => {
            this.onKeyup();
          },
          keydown: (view, event) => {
            this.onKeydown(event);
          },
          focus: () => {
            this.onFocus();
          },
          click: () => {
            this.isEditorMouseFocusedOnAnImage();
          },
          blur: () => {
            this.onBlur();
          },
          paste: (view, event) => {
            const data = event.clipboardData.files;
            if (data.length > 0) {
              event.preventDefault();
            }
          },
        },
      });
    },
    isEditorMouseFocusedOnAnImage() {
      if (!this.showImageResizeToolbar) {
        return;
      }
      this.selectedImageNode = document.querySelector(
        'img.ProseMirror-selectednode'
      );
      if (this.selectedImageNode) {
        this.isImageNodeSelected = !!this.selectedImageNode;
        // Get the position of the selected node
        this.setToolbarPosition();
      } else {
        this.isImageNodeSelected = false;
      }
    },
    setToolbarPosition() {
      const editorRect = this.editorRoot.getBoundingClientRect();
      const rect = this.selectedImageNode.getBoundingClientRect();
      this.toolbarPosition = {
        top: `${rect.top - editorRect.top - 30}px`,
        left: `${rect.left - editorRect.left - 4}px`,
      };
    },
    setURLWithQueryAndImageSize(size) {
      if (!this.showImageResizeToolbar) {
        return;
      }
      setURLWithQueryAndSize(this.selectedImageNode, size, this.editorView);
      this.isImageNodeSelected = false;
    },
    updateImgToolbarOnDelete() {
      // check if the selected node is present or not on keyup
      // this is needed because the user can select an image and then delete it
      // in that case, the selected node will be null and we need to hide the toolbar
      // otherwise, the toolbar will be visible even when the image is deleted and cause some errors
      if (this.selectedImageNode) {
        const hasImgSelectedNode = document.querySelector(
          'img.ProseMirror-selectednode'
        );
        if (!hasImgSelectedNode) {
          this.isImageNodeSelected = false;
        }
      }
    },
    isEnterToSendEnabled() {
      return this.isEditorHotKeyEnabled('enter');
    },
    isCmdPlusEnterToSendEnabled() {
      return this.isEditorHotKeyEnabled('cmd_enter');
    },
    getKeyboardEvents() {
      return {
        'Alt+KeyP': {
          action: () => {
            this.focusEditorInputField();
          },
          allowOnFocusedInput: true,
        },
        'Alt+KeyL': {
          action: () => {
            this.focusEditorInputField();
          },
          allowOnFocusedInput: true,
        },
      };
    },
    focusEditorInputField(pos = 'end') {
      const { tr } = this.editorView.state;

      const selection =
        pos === 'end' ? Selection.atEnd(tr.doc) : Selection.atStart(tr.doc);

      this.editorView.dispatch(tr.setSelection(selection));
      this.editorView.focus();
    },
    insertMentionNode(mentionItem) {
      if (!this.editorView) {
        return null;
      }
      const node = this.editorView.state.schema.nodes.mention.create({
        userId: mentionItem.id,
        userFullName: mentionItem.name,
      });

      this.insertNodeIntoEditor(node, this.range.from, this.range.to);
      this.$track(CONVERSATION_EVENTS.USED_MENTIONS);

      return false;
    },
    insertCannedResponse(cannedItem) {
      const updatedMessage = replaceVariablesInMessage({
        message: cannedItem,
        variables: this.variables,
      });

      if (!this.editorView) {
        return null;
      }

      let node = new MessageMarkdownTransformer(messageSchema).parse(
        updatedMessage
      );

      const from =
        node.textContent === updatedMessage
          ? this.range.from
          : this.range.from - 1;

      this.insertNodeIntoEditor(node, from, this.range.to);

      this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
      return false;
    },
    insertVariable(variable) {
      if (!this.editorView) {
        return null;
      }

      const content = `{{${variable}}}`;
      let node = this.editorView.state.schema.text(content);
      const { from, to } = this.range;

      this.insertNodeIntoEditor(node, from, to);
      this.showVariables = false;
      this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE);
      return false;
    },
    openFileBrowser() {
      this.$refs.imageUpload.click();
    },
    onFileChange() {
      const file = this.$refs.imageUpload.files[0];
      if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
        this.uploadImageToStorage(file);
      } else {
        useAlert(
          this.$t(
            'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SIZE_ERROR',
            {
              size: MAXIMUM_FILE_UPLOAD_SIZE,
            }
          )
        );
      }

      this.$refs.imageUpload.value = '';
    },
    async uploadImageToStorage(file) {
      try {
        const { fileUrl } = await uploadFile(file);
        if (fileUrl) {
          this.onImageInsertInEditor(fileUrl);
        }
        useAlert(
          this.$t(
            'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS'
          )
        );
      } catch (error) {
        useAlert(
          this.$t(
            'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_ERROR'
          )
        );
      }
    },
    onImageInsertInEditor(fileUrl) {
      const { tr } = this.editorView.state;

      const insertData = findNodeToInsertImage(this.editorView.state, fileUrl);

      if (insertData) {
        this.editorView.dispatch(
          tr.insert(insertData.pos, insertData.node).scrollIntoView()
        );
        this.focusEditorInputField();
      }
    },

    emitOnChange() {
      this.$emit('input', this.contentFromEditor);
    },

    hideMentions() {
      this.showUserMentions = false;
    },
    handleLineBreakWhenEnterToSendEnabled(event) {
      if (
        hasPressedEnterAndNotCmdOrShift(event) &&
        this.isEnterToSendEnabled() &&
        !this.overrideLineBreaks
      ) {
        event.preventDefault();
      }
    },
    handleLineBreakWhenCmdAndEnterToSendEnabled(event) {
      if (
        hasPressedCommandAndEnter(event) &&
        this.isCmdPlusEnterToSendEnabled() &&
        !this.overrideLineBreaks
      ) {
        event.preventDefault();
      }
    },
    onKeyup() {
      this.typingIndicator.start();
      this.updateImgToolbarOnDelete();
    },
    onKeydown(event) {
      if (this.isEnterToSendEnabled()) {
        this.handleLineBreakWhenEnterToSendEnabled(event);
      }
      if (this.isCmdPlusEnterToSendEnabled()) {
        this.handleLineBreakWhenCmdAndEnterToSendEnabled(event);
      }
    },
    onBlur() {
      this.typingIndicator.stop();
      this.$emit('blur');
    },
    onFocus() {
      this.$emit('focus');
    },
    insertContentIntoEditor(content, defaultFrom = 0) {
      const from = defaultFrom || this.editorView.state.selection.from || 0;
      let node = new MessageMarkdownTransformer(messageSchema).parse(content);

      this.insertNodeIntoEditor(node, from, undefined);
    },
    insertNodeIntoEditor(node, from = 0, to = 0) {
      this.state = insertAtCursor(this.editorView, node, from, to);
      this.emitOnChange();
      this.$nextTick(() => {
        scrollCursorIntoView(this.editorView);
      });
    },
  },
};
</script>

<template>
  <div ref="editorRoot" class="relative editor-root">
    <TagAgents
      v-if="showUserMentions && isPrivate"
      :search-key="mentionSearchKey"
      @click="insertMentionNode"
    />
    <CannedResponse
      v-if="shouldShowCannedResponses"
      :search-key="cannedSearchTerm"
      @click="insertCannedResponse"
    />
    <VariableList
      v-if="shouldShowVariables"
      :search-key="variableSearchTerm"
      @click="insertVariable"
    />
    <input
      ref="imageUpload"
      type="file"
      accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
      hidden
      @change="onFileChange"
    />
    <div ref="editor" />
    <div
      v-show="isImageNodeSelected && showImageResizeToolbar"
      class="absolute shadow-md rounded-[4px] flex gap-1 py-1 px-1 bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-50"
      :style="{
        top: toolbarPosition.top,
        left: toolbarPosition.left,
      }"
    >
      <button
        v-for="size in sizes"
        :key="size.name"
        class="text-xs font-medium rounded-[4px] border border-solid border-slate-200 dark:border-slate-600 px-1.5 py-0.5 hover:bg-slate-100 dark:hover:bg-slate-800"
        @click="setURLWithQueryAndImageSize(size)"
      >
        {{ size.name }}
      </button>
    </div>
    <slot name="footer" />
  </div>
</template>

<style lang="scss">
@import '~@chatwoot/prosemirror-schema/src/styles/base.scss';

.ProseMirror-menubar-wrapper {
  @apply flex flex-col;
  .ProseMirror-menubar {
    min-height: var(--space-two) !important;
    @apply -ml-2.5 pb-0 bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-100;

    .ProseMirror-menu-active {
      @apply bg-slate-75 dark:bg-slate-800;
    }
  }
  > .ProseMirror {
    @apply p-0 break-words text-slate-800 dark:text-slate-100;

    h1,
    h2,
    h3,
    h4,
    h5,
    h6,
    p {
      @apply text-slate-800 dark:text-slate-100;
    }

    blockquote {
      @apply border-slate-400 dark:border-slate-500;

      p {
        @apply text-slate-600 dark:text-slate-400;
      }
    }

    ol li {
      @apply list-item list-decimal;
    }
  }
}

.editor-root {
  @apply w-full relative;
}

.ProseMirror-woot-style {
  @apply overflow-auto min-h-[5rem] max-h-[7.5rem];
}

.ProseMirror-prompt {
  @apply z-[9999] bg-slate-25 dark:bg-slate-700 rounded-md border border-solid border-slate-75 dark:border-slate-800 shadow-lg;

  h5 {
    @apply dark:text-slate-25 text-slate-800;
  }
}

.is-private {
  .prosemirror-mention-node {
    @apply font-medium bg-yellow-100 dark:bg-yellow-800 text-slate-900 dark:text-slate-25 py-0 px-1;
  }

  .ProseMirror-menubar-wrapper {
    .ProseMirror-menubar {
      @apply bg-yellow-100 dark:bg-yellow-800 text-slate-700 dark:text-slate-25;
    }

    > .ProseMirror {
      @apply text-slate-800 dark:text-slate-25;

      p {
        @apply text-slate-800 dark:text-slate-25;
      }
    }
  }
}

.editor-wrap {
  @apply mb-4;
}

.message-editor {
  @apply border border-solid border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-900 rounded-md py-0 px-1 mb-0;
}

.editor_warning {
  @apply border border-solid border-red-400 dark:border-red-400;
}

.editor-warning__message {
  @apply text-red-400 dark:text-red-400 font-normal text-sm pt-1 pb-0 px-0;
}
</style>
