/*
 * https://github.com/morethanwords/tweb
 * Copyright (C) 2019-2021 Eduard Kuzmenko
 * https://github.com/morethanwords/tweb/blob/master/LICENSE
 */

import type {EMOJI_VERSION} from '../../environment/emojiVersionsSupport';
import {SITE_HASHTAGS} from '.';
import {EmojiVersions} from '../../config/emoji';
import IS_EMOJI_SUPPORTED from '../../environment/emojiSupport';
import buildURLHash from '../../helpers/buildURLHash';
import copy from '../../helpers/object/copy';
import encodeEntities from '../../helpers/string/encodeEntities';
import {MessageEntity} from '../../layer';
import encodeSpoiler from './encodeSpoiler';
import parseEntities from './parseEntities';
import setBlankToAnchor from './setBlankToAnchor';
import wrapUrl from './wrapUrl';
import EMOJI_VERSIONS_SUPPORTED from '../../environment/emojiVersionsSupport';
import {CLICK_EVENT_NAME} from '../../helpers/dom/clickEvent';
import IS_CUSTOM_EMOJI_SUPPORTED from '../../environment/customEmojiSupport';
import rootScope from '../rootScope';
import mediaSizes from '../../helpers/mediaSizes';
import wrapSticker, {videosCache} from '../../components/wrappers/sticker';
import RLottiePlayer, {getLottiePixelRatio} from '../rlottie/rlottiePlayer';
import animationIntersector, {AnimationItemGroup} from '../../components/animationIntersector';
import type {MyDocument} from '../appManagers/appDocsManager';
import LazyLoadQueue from '../../components/lazyLoadQueue';
import {Awaited} from '../../types';
import {MediaSize} from '../../helpers/mediaSize';
import IS_WEBM_SUPPORTED from '../../environment/webmSupport';
import assumeType from '../../helpers/assumeType';
import noop from '../../helpers/noop';
import findUpClassName from '../../helpers/dom/findUpClassName';
import getViewportSlice from '../../helpers/dom/getViewportSlice';
import {getMiddleware, Middleware, MiddlewareHelper} from '../../helpers/middleware';
import replaceContent from '../../helpers/dom/replaceContent';
import BOM from '../../helpers/string/bom';
import framesCache from '../../helpers/framesCache';
import wrapTelegramUrlToAnchor from './wrapTelegramUrlToAnchor';

const resizeObserver = new ResizeObserver((entries) => {
  for(const entry of entries) {
    const renderer = entry.target.parentElement as CustomEmojiRendererElement;
    renderer.setDimensionsFromRect(entry.contentRect);
  }
});

const globalLazyLoadQueue = new LazyLoadQueue();

export class CustomEmojiElement extends HTMLElement {
  public elements: CustomEmojiElements;
  public renderer: CustomEmojiRendererElement;
  public player: RLottiePlayer | HTMLVideoElement;
  public paused: boolean;
  public syncedPlayer: SyncedPlayer;
  public clean: boolean;
  public lastChildWas: Node;
  // public docId: DocId;
  public placeholder: HTMLImageElement;
  public middlewareHelper: MiddlewareHelper;

  constructor() {
    super();
    this.paused = true;
    this.classList.add('custom-emoji');
  }

  public get docId() {
    return this.dataset.docId;
  }

  public static create(docId: DocId) {
    const element = new CustomEmojiElement();
    // element.docId = docId;
    element.dataset.docId = '' + docId;
    return element;
  }

  public get isConnected() {
    return this.placeholder?.isConnected ?? super.isConnected;
  }

  public connectedCallback() {
    // if(this.isConnected) {
    //   return;
    // }

    if(this.player) {
      animationIntersector.addAnimation({
        animation: this,
        group: this.renderer.animationGroup,
        controlled: true
      });
    }

    // this.connectedCallback = undefined;
  }

  public disconnectedCallback() {
    if(this.isConnected || !this.renderer?.isSelectable) { // prepend on sibling can invoke disconnectedCallback
      return;
    }

    this.clear();
  }

  public destroy() {
    this.clear();
  }

  public clear(replaceChildren = true) {
    if(this.clean) {
      return;
    }

    // if(this.docId === '5399836826758290421') {
    //   console.log('clear', this, this.isConnected);
    // }

    this.clean = true;
    this.pause();

    const {syncedPlayer} = this;
    if(syncedPlayer) {
      syncedPlayer.pausedElements.delete(this);
    }

    this.middlewareHelper?.clean();

    if(this.renderer) {
      const elements = this.renderer.customEmojis.get(this.docId);
      if(elements?.delete(this) && !elements.size) {
        this.renderer.customEmojis.delete(this.docId);
        this.renderer.playersSynced.delete(elements);
      }

      if(replaceChildren) {
        if(this.renderer.isSelectable) {
          this.replaceChildren(createCustomFiller(true));
        } else {
          // otherwise https://bugs.chromium.org/p/chromium/issues/detail?id=1144736#c27 will happen
          this.replaceChildren();
        }
      }
    }

    if(this.player) {
      animationIntersector.removeAnimationByPlayer(this);
    }

    if(globalLazyLoadQueue) {
      globalLazyLoadQueue.delete({div: this});
    }

    /* this.disconnectedCallback =  */this.elements =
      this.renderer =
      this.player =
      this.syncedPlayer =
      undefined;
  }

  public pause() {
    if(this.paused) {
      return;
    }

    this.paused = true;

    if(this.player instanceof HTMLVideoElement && !this.syncedPlayer) {
      this.renderer.lastPausedVideo = this.player;
      this.player.pause();
    }

    if(this.syncedPlayer && !this.syncedPlayer.pausedElements.has(this)) {
      this.syncedPlayer.pausedElements.add(this);

      if(this.syncedPlayer.player && this.syncedPlayer.pausedElements.size === this.syncedPlayer.middlewares.size) {
        this.syncedPlayer.player.pause();
      }
    }
  }

  public play() {
    if(!this.paused) {
      return;
    }

    this.paused = false;

    if(this.player instanceof HTMLVideoElement) {
      this.player.currentTime = this.renderer.lastPausedVideo?.currentTime ?? this.player.currentTime;
      this.player.play().catch(noop);
    }

    if(this.syncedPlayer && this.syncedPlayer.pausedElements.has(this)) {
      this.syncedPlayer.pausedElements.delete(this);

      if(this.syncedPlayer.pausedElements.size !== this.syncedPlayer.middlewares.size) {
        this.player.play();
      }
    }
  }

  public remove() {
    this.clear();
    // this.elements = this.renderer = this.player = undefined;
  }

  public get autoplay() {
    return true;
  }

  public get loop() {
    return true;
  }
}

type CustomEmojiElements = Set<CustomEmojiElement>;

export class CustomEmojiRendererElement extends HTMLElement {
  public canvas: HTMLCanvasElement;
  public context: CanvasRenderingContext2D;

  public playersSynced: Map<CustomEmojiElements, RLottiePlayer | HTMLVideoElement>;
  public clearedElements: WeakSet<CustomEmojiElements>;
  public customEmojis: Parameters<typeof wrapRichText>[1]['customEmojis'];
  public lastPausedVideo: HTMLVideoElement;

  public lastRect: DOMRect;
  public isDimensionsSet: boolean;

  public animationGroup: AnimationItemGroup;
  public size: MediaSize;

  public isSelectable: boolean;
  public isCanvasClean: boolean;

  public ignoreSettingDimensions: boolean;

  public forceRenderAfterSize: boolean;

  public middlewareHelper: MiddlewareHelper;

  public auto: boolean;

  constructor() {
    super();

    this.classList.add('custom-emoji-renderer');
    this.canvas = document.createElement('canvas');
    this.canvas.classList.add('custom-emoji-canvas');
    this.context = this.canvas.getContext('2d');
    this.append(this.canvas);

    this.playersSynced = new Map();
    this.clearedElements = new WeakSet();
    this.customEmojis = new Map();

    this.animationGroup = 'EMOJI';
    this.isCanvasClean = false;
  }

  public connectedCallback() {
    if(emojiRenderers.has(this)) {
      return;
    }

    // this.setDimensions();
    // animationIntersector.addAnimation(this, this.animationGroup);
    resizeObserver.observe(this.canvas);
    emojiRenderers.add(this);

    this.connectedCallback = undefined;
  }

  public disconnectedCallback() {
    if(this.isConnected || !this.auto) {
      return;
    }

    this.destroy?.();

    this.disconnectedCallback = undefined;
  }

  public destroy() {
    // if(this.isConnected) {
    //   return;
    // }

    resizeObserver.unobserve(this.canvas);

    this.customEmojis.forEach((elements) => {
      elements.forEach((element) => {
        element.clear();
      });
    });

    emojiRenderers.delete(this);
    this.playersSynced.clear();
    this.middlewareHelper?.clean();
    this.customEmojis.clear();

    this.destroy =
      this.lastPausedVideo =
      undefined;
  }

  public getOffsets(offsetsMap: Map<CustomEmojiElements, {top: number, left: number, width: number}[]> = new Map()) {
    if(!this.playersSynced.size) {
      return offsetsMap;
    }

    const overflowElement = findUpClassName(this, 'scrollable') || this.offsetParent as HTMLElement;
    if(!overflowElement) {
      return offsetsMap;
    }

    const overflowRect = overflowElement.getBoundingClientRect();
    const rect = this.getBoundingClientRect();

    for(const elements of this.playersSynced.keys()) {
      const elementsArr = Array.from(elements);
      const placeholders = this.isSelectable ? elementsArr.map((element) => element.placeholder) : elementsArr;
      const {visible} = getViewportSlice({
        overflowElement,
        overflowRect,
        elements: placeholders,
        extraSize: this.size.height * 2.5 // let's add some margin
      });

      const offsets = visible.map(({rect: elementRect}) => {
        const top = elementRect.top - rect.top;
        const left = elementRect.left - rect.left;
        return {top, left, width: elementRect.width};
      });

      if(offsets.length) {
        offsetsMap.set(elements, offsets);
      }
    }

    // const rect = this.getBoundingClientRect();
    // const visibleRect = getVisibleRect(this, overflowElement, undefined, rect);
    // const minTop = visibleRect ? visibleRect.rect.top - this.size.height : 0;
    // const maxTop = Infinity;
    // for(const elements of this.playersSynced.keys()) {
    //   const offsets = elements.map((element) => {
    //     const elementRect = element.getBoundingClientRect();
    //     const top = elementRect.top - rect.top;
    //     const left = elementRect.left - rect.left;
    //     return top >= minTop && (top + elementRect.height) <= maxTop ? {top, left} : undefined;
    //   }).filter(Boolean);

    //   if(offsets.length) {
    //     offsetsMap.set(elements, offsets);
    //   }
    // }

    return offsetsMap;
  }

  public clearCanvas() {
    if(this.isCanvasClean) {
      return;
    }

    const {context, canvas} = this;
    context.clearRect(0, 0, canvas.width, canvas.height);
    this.isCanvasClean = true;
  }

  public render(offsetsMap: ReturnType<CustomEmojiRendererElement['getOffsets']>) {
    const {context, canvas, isDimensionsSet} = this;
    if(!isDimensionsSet) {
      this.setDimensionsFromRect(undefined, false);
    }

    this.isCanvasClean = false;

    const {width, height, dpr} = canvas;
    for(const [elements, offsets] of offsetsMap) {
      const player = this.playersSynced.get(elements);
      const frame = syncedPlayersFrames.get(player) || (player instanceof HTMLVideoElement ? player : undefined);
      if(!frame) {
        continue;
      }

      const isImageData = frame instanceof ImageData;
      let frameWidth: number, frameHeight: number;
      if(player instanceof HTMLVideoElement) {
        frameWidth = this.size.width * dpr;
        frameHeight = this.size.height * dpr;
      } else {
        frameWidth = frame.width;
        frameHeight = frame.height;
      }

      // ! check performance of scaling
      const elementWidth = Math.round(offsets[0].width * dpr);
      if(elementWidth !== frameWidth) {
        // if(this.size.width === 36) {
        //   console.warn('different width', elementWidth, frameWidth, this);
        // }

        frameWidth = elementWidth;
        frameHeight = elementWidth;
      }

      const maxTop = height - frameHeight;
      const maxLeft = width - frameWidth;

      if(!this.clearedElements.has(elements) && !this.isSelectable) {
        if(this.isSelectable/*  && false */) {
          elements.forEach((element) => {
            element.lastChildWas ??= element.lastChild;
            replaceContent(element, element.firstChild);
          });
        } else {
          elements.forEach((element) => {
            element.replaceChildren();
          });
        }

        this.clearedElements.add(elements);
      }

      offsets.forEach(({top, left}) => {
        top = Math.round(top * dpr), left = Math.round(left * dpr);
        if(left < 0 ||/* top > maxTop ||  */left > maxLeft) {
          return;
        }

        if(isImageData) {
          context.putImageData(frame, left, top);
        } else {
          // context.clearRect(left, top, width, height);
          context.drawImage(frame, left, top, frameWidth, frameHeight);
        }
      });
    }
  }

  public checkForAnyFrame() {
    for(const player of this.playersSynced.values()) {
      if(syncedPlayersFrames.has(player) || player instanceof HTMLVideoElement) {
        return true;
      }
    }

    return false;
  }

  public remove() {
    super.remove();
    // this.canvas.remove();
  }

  // public setDimensions() {
  //   const {canvas} = this;
  //   sequentialDom.mutateElement(canvas, () => {
  //     const rect = canvas.getBoundingClientRect();
  //     this.setDimensionsFromRect(rect);
  //   });
  // }

  public setDimensionsFromRect(rect: DOMRect = this.lastRect, forceRenderAfter = true) {
    const {canvas} = this;
    const {dpr} = canvas;

    if(this.lastRect !== rect) {
      this.lastRect = rect;
    }

    if(!rect || !dpr || this.ignoreSettingDimensions) {
      return;
    }

    const {width, height} = rect;
    // if(this.isSelectable) {
    //   height = this.parentElement.scrollHeight || height;

    //   this.style.width = width + 'px';
    //   this.style.height = height + 'px';
    // }

    const newWidth = Math.floor(Math.round(width * dpr));
    const newHeight = Math.floor(Math.round(height * dpr));
    if(canvas.width === newWidth && canvas.height === newHeight) {
      return;
    }

    canvas.width = newWidth;
    canvas.height = newHeight;
    this.isDimensionsSet = true;
    this.isCanvasClean = true;

    if(this.forceRenderAfterSize || (this.isSelectable && forceRenderAfter)) {
      this.forceRenderAfterSize = undefined;
      this.forceRender();
    }
  }

  public forceRender() {
    if(!this.isDimensionsSet) {
      return;
    }

    if(!renderEmojis(new Set([this]))) {
      this.clearCanvas();
    }
  }

  public add(
    addCustomEmojis: Parameters<typeof wrapRichText>[1]['customEmojis'],
    lazyLoadQueue?: LazyLoadQueue | false,
    onlyThumb?: boolean,
    withThumb?: boolean
  ) {
    const renderer = this;

    addCustomEmojis.forEach((addElements, docId) => { // prevent adding old elements
      let elements = this.customEmojis.get(docId);
      if(!elements) this.customEmojis.set(docId, elements = new Set());
      else this.clearedElements.delete(elements);

      for(const el of addElements) {
        if(elements.has(el)) {
          addElements.delete(el);
        } else {
          el.clean = false;
          el.renderer = renderer;
          el.elements = elements;
          el.middlewareHelper = this.middlewareHelper.get().create();
          elements.add(el);

          if(el.lastChildWas && !el.lastChildWas.parentNode) {
            el.append(el.lastChildWas);
          }
        }
      }

      if(!addElements.size) {
        addCustomEmojis.delete(docId);
      }
    });

    if(!addCustomEmojis.size) {
      return;
    }

    const usingOwnQueue = !!(!lazyLoadQueue && lazyLoadQueue !== false && globalLazyLoadQueue);

    const docIds = Array.from(addCustomEmojis.keys());

    const managers = rootScope.managers;
    const middleware = this.middlewareHelper.get();
    const size = this.size;

    const loadPromise = managers.appEmojiManager.getCachedCustomEmojiDocuments(docIds).then((docs) => {
      if(middleware && !middleware()) return;

      const loadPromises: Promise<any>[] = [];
      const wrap = (doc: MyDocument, _loadPromises?: Promise<any>[]) => {
        const docId = doc.id;
        const newElements = addCustomEmojis.get(docId);
        const customEmojis = renderer.customEmojis.get(docId);
        const isLottie = doc.sticker === 2;
        const isStatic = doc.mime_type === 'video/webm' && !IS_WEBM_SUPPORTED;
        const willHaveSyncedPlayer = (isLottie || (doc.sticker === 3 && this.isSelectable)) && !onlyThumb && !isStatic;

        const loadPromises: Promise<any>[] = [];
        const newElementsArray = Array.from(newElements);
        const promise = wrapSticker({
          div: newElementsArray,
          doc,
          width: size.width,
          height: size.height,
          loop: true,
          play: CUSTOM_EMOJI_INSTANT_PLAY,
          managers,
          isCustomEmoji: true,
          group: 'none',
          loadPromises,
          middleware,
          exportLoad: usingOwnQueue || lazyLoadQueue === false ? 2 : 1, // 2 - export load always, 1 - do not export load if cached static
          needFadeIn: false,
          loadStickerMiddleware: willHaveSyncedPlayer && middleware ? middleware.create().get(() => {
            // if(syncedPlayers.get(key) !== syncedPlayer) {
            //   return false;
            // }

            // let good = false;
            // for(const middleware of syncedPlayer.middlewares) {
            //   if(middleware()) {
            //     good = true;
            //     break;
            //   }
            // }

            // return good;
            return !!syncedPlayer.middlewares.size;
          }) : undefined,
          static: isStatic,
          onlyThumb,
          withThumb: withThumb ?? (renderer.clearedElements.has(customEmojis) ? false : undefined),
          syncedVideo: this.isSelectable
        });

        if(_loadPromises) {
          promise.then(() => _loadPromises.push(...loadPromises));
        }

        const addition: {
          onRender?: (_p: Awaited<Awaited<typeof promise>['render']>) => Promise<void>,
          elements: typeof newElements
        } = {
          elements: newElements
        };

        if(doc.sticker === 1 || onlyThumb || isStatic) {
          if(this.isSelectable) {
            addition.onRender = () => Promise.all(loadPromises).then(() => {
              if(middleware && !middleware()) return;
              newElementsArray.forEach((element) => {
                const {placeholder} = element;
                placeholder.src = (element.firstElementChild as HTMLImageElement).src;
              });
            });
          }

          return promise.then((res) => ({...res, ...addition}));
        }

        // eslint-disable-next-line prefer-const
        addition.onRender = (_p) => Promise.all(loadPromises).then(() => {
          if((middleware && !middleware()) || !doc.animated) {
            return;
          }

          const players = Array.isArray(_p) ? _p as HTMLVideoElement[] : [_p as RLottiePlayer];
          const player = Array.isArray(players) ? players[0] : players;
          assumeType<RLottiePlayer | HTMLVideoElement>(player);
          newElementsArray.forEach((element, idx) => {
            const player = players[idx] || players[0];
            element.player = player;

            if(syncedPlayer) {
              element.syncedPlayer = syncedPlayer;
              if(element.paused) {
                element.syncedPlayer.pausedElements.add(element);
              } else if(player.paused) {
                player.play();
              }
            }

            if(element.isConnected) {
              animationIntersector.addAnimation({
                animation: element,
                group: element.renderer.animationGroup,
                controlled: true
              });
            }
          });

          if(player instanceof RLottiePlayer || (player instanceof HTMLVideoElement && this.isSelectable)) {
            syncedPlayer.player = player;
            renderer.playersSynced.set(customEmojis, player);
          }

          if(player instanceof RLottiePlayer) {
            player.group = renderer.animationGroup;

            player.overrideRender ??= (frame) => {
              syncedPlayersFrames.set(player, frame);
              // frames.set(containers, frame);
            };
          } else if(player instanceof HTMLVideoElement) {
            // player.play();

            // const cache = framesCache.getCache(key);
            // let {width, height} = renderer.size;
            // width *= dpr;
            // height *= dpr;

            // const onFrame = (frame: ImageBitmap | HTMLCanvasElement) => {
            //   topFrames.set(player, frame);
            //   player.requestVideoFrameCallback(callback);
            // };

            // let frameNo = -1, lastTime = 0;
            // const callback: VideoFrameRequestCallback = (now, metadata) => {
            //   const time = player.currentTime;
            //   if(lastTime > time) {
            //     frameNo = -1;
            //   }

            //   const _frameNo = ++frameNo;
            //   lastTime = time;
            //   // const frameNo = Math.floor(player.currentTime * 1000 / CUSTOM_EMOJI_FRAME_INTERVAL);
            //   // const frameNo = metadata.presentedFrames;
            //   const imageBitmap = cache.framesNew.get(_frameNo);

            //   if(imageBitmap) {
            //     onFrame(imageBitmap);
            //   } else if(IS_IMAGE_BITMAP_SUPPORTED) {
            //     createImageBitmap(player, {resizeWidth: width, resizeHeight: height}).then((imageBitmap) => {
            //       cache.framesNew.set(_frameNo, imageBitmap);
            //       if(frameNo === _frameNo) onFrame(imageBitmap);
            //     });
            //   } else {
            //     const canvas = document.createElement('canvas');
            //     const context = canvas.getContext('2d');
            //     canvas.width = width;
            //     canvas.height = height;
            //     context.drawImage(player, 0, 0);
            //     cache.framesNew.set(_frameNo, canvas);
            //     onFrame(canvas);
            //   }
            // };

            // // player.requestVideoFrameCallback(callback);
            // // setInterval(callback, CUSTOM_EMOJI_FRAME_INTERVAL);
          }

          if(willHaveSyncedPlayer) {
            const dpr = getLottiePixelRatio(this.size.width, this.size.height);
            renderer.canvas.dpr = dpr;
            setRenderInterval();
          }
        });

        let syncedPlayer: SyncedPlayer;
        const key = [docId, size.width, size.height].join('-');
        if(willHaveSyncedPlayer) {
          syncedPlayer = syncedPlayers.get(key);
          if(!syncedPlayer) {
            syncedPlayer = {
              player: undefined,
              middlewares: new Set(),
              pausedElements: new Set(),
              key
            };

            syncedPlayers.set(key, syncedPlayer);
          }

          for(const element of newElements) {
            const middleware = element.middlewareHelper.get();
            syncedPlayer.middlewares.add(middleware);
            middleware.onClean(() => {
              syncedPlayer.middlewares.delete(middleware);

              if(!syncedPlayer.middlewares.size) {
                if(syncedPlayer.player) {
                  const frame = syncedPlayersFrames.get(syncedPlayer.player);
                  if(frame) {
                    (frame as ImageBitmap).close?.();
                    syncedPlayersFrames.delete(syncedPlayer.player);
                  }

                  syncedPlayersFrames.delete(syncedPlayer.player);
                  if(syncedPlayer.player instanceof RLottiePlayer) {
                    syncedPlayer.player.overrideRender = noop;
                    syncedPlayer.player.remove();
                  } else if(syncedPlayer.player instanceof HTMLVideoElement) {
                    const cacheName = framesCache.generateName('' + element.docId, 0, 0, undefined, undefined);
                    delete videosCache[cacheName];
                  }

                  syncedPlayer.player = undefined;
                }

                if(syncedPlayers.get(syncedPlayer.key) === syncedPlayer && syncedPlayers.delete(syncedPlayer.key) && !syncedPlayers.size) {
                  clearRenderInterval();
                }
              }
            });
          }
        }

        return promise.then((res) => ({...res, ...addition}));
      };

      const missing: DocId[] = [];
      const cachedPromises = docs.map((doc, idx) => {
        if(!doc) {
          missing.push(docIds[idx]);
          return;
        }

        return wrap(doc, loadPromises);
      }).filter(Boolean);

      const uncachedPromisesPromise = !missing.length ?
        Promise.resolve([] as typeof cachedPromises) :
        managers.appEmojiManager.getCustomEmojiDocuments(missing).then((docs) => {
          if(middleware && !middleware()) return [];
          return docs.filter(Boolean).map((doc) => wrap(doc));
        });

      const loadFromPromises = (promises: typeof cachedPromises) => {
        return Promise.all(promises).then((arr) => {
          const promises = arr.map(({load, onRender, elements}) => {
            if(!load) {
              return;
            }

            const l = () => load().then(onRender);

            if(usingOwnQueue) {
              elements.forEach((element) => {
                globalLazyLoadQueue.push({
                  div: element,
                  load: () => {
                    elements.forEach((element) => {
                      globalLazyLoadQueue.delete({div: element});
                    });

                    return l();
                  }
                });
              });
            } else {
              return l();
            }
          });

          return Promise.all(promises);
        });
      };

      const load = () => {
        if(middleware && !middleware()) return;
        const cached = loadFromPromises(cachedPromises);
        const uncached = uncachedPromisesPromise.then((promises) => loadFromPromises(promises));
        return Promise.all([cached, uncached]);
      };

      if(lazyLoadQueue) {
        lazyLoadQueue.push({
          div: renderer.canvas,
          load
        });
      } else {
        load();
      }

      return Promise.all(cachedPromises).then(() => Promise.all(loadPromises)).then(() => {});
    });

    // recordPromise(loadPromise, 'render emojis: ' + docIds.length);

    return loadPromise;
  }

  public static create(options: CustomEmojiRendererElementOptions) {
    const renderer = new CustomEmojiRendererElement();
    renderer.animationGroup = options.animationGroup;
    renderer.size = options.customEmojiSize || mediaSizes.active.customEmoji;
    renderer.isSelectable = options.isSelectable;
    if(options.wrappingDraft) {
      renderer.contentEditable = 'false';
      renderer.style.height = 'inherit';
    }
    // const middleware = () => !!renderer.disconnectedCallback && (!options.middleware || options.middleware());
    let middleware = options.middleware;
    if(middleware) {
      renderer.middlewareHelper = middleware.create();
      middleware = renderer.middlewareHelper.get();
      middleware.onDestroy(() => {
        renderer.destroy?.();
      });
    } else {
      // console.error('no middleware', this, options);
      renderer.auto = true;
      renderer.middlewareHelper = getMiddleware();
    }

    return renderer;
  }
}

type CustomEmojiRenderer = CustomEmojiRendererElement;
type SyncedPlayer = {
  player: RLottiePlayer | HTMLVideoElement,
  middlewares: Set<Middleware>,
  pausedElements: Set<CustomEmojiElement>,
  key: string
};
type CustomEmojiFrame = Parameters<RLottiePlayer['overrideRender']>[0] | HTMLVideoElement;

const CUSTOM_EMOJI_INSTANT_PLAY = true; // do not wait for animationIntersector
let emojiRenderInterval: number;
const emojiRenderers: Set<CustomEmojiRenderer> = new Set();
const syncedPlayers: Map<string, SyncedPlayer> = new Map();
const syncedPlayersFrames: Map<RLottiePlayer | HTMLVideoElement, CustomEmojiFrame> = new Map();
export const renderEmojis = (renderers = emojiRenderers) => {
  const r = Array.from(renderers);
  const t = r.filter((r) => r.isConnected && r.checkForAnyFrame() && !r.ignoreSettingDimensions);
  if(!t.length) {
    return false;
  }

  const o = t.map((renderer) => {
    const paused = [...renderer.playersSynced.values()].reduce((acc, v) => acc + +!!v.paused, 0);
    if(renderer.playersSynced.size === paused) {
      return;
    }

    const offsets = renderer.getOffsets();
    if(offsets.size) {
      return [renderer, offsets] as const;
    }
  }).filter(Boolean);

  for(const [renderer] of o) {
    renderer.clearCanvas();
  }

  for(const [renderer, offsets] of o) {
    renderer.render(offsets);
  }

  return true;
};
const CUSTOM_EMOJI_FPS = 60;
const CUSTOM_EMOJI_FRAME_INTERVAL = 1000 / CUSTOM_EMOJI_FPS;
const setRenderInterval = () => {
  if(emojiRenderInterval) {
    return;
  }

  emojiRenderInterval = window.setInterval(renderEmojis, CUSTOM_EMOJI_FRAME_INTERVAL);
  renderEmojis();
};
const clearRenderInterval = () => {
  if(!emojiRenderInterval) {
    return;
  }

  clearInterval(emojiRenderInterval);
  emojiRenderInterval = undefined;
};

(window as any).syncedPlayers = syncedPlayers;
(window as any).emojiRenderers = emojiRenderers;

customElements.define('custom-emoji-element', CustomEmojiElement);
customElements.define('custom-emoji-renderer-element', CustomEmojiRendererElement);

type CustomEmojiRendererElementOptions = Partial<{
  loadPromises: Promise<any>[],
  customEmojiRenderer: CustomEmojiRendererElement,

  isSelectable: boolean,
  wrappingDraft: boolean
}> & WrapSomethingOptions;

/**
 * * Expecting correctly sorted nested entities (RichTextProcessor.sortEntities)
 */
export default function wrapRichText(text: string, options: Partial<{
  entities: MessageEntity[],
  contextSite: string,
  highlightUsername: string,
  noLinks: boolean,
  noLinebreaks: boolean,
  noCommands: boolean,
  wrappingDraft: boolean,
  // mustWrapEmoji: boolean,
  fromBot: boolean,
  noTextFormat: boolean,
  passEntities: Partial<{
    [_ in MessageEntity['_']]: boolean
  }>,
  maxMediaTimestamp: number,
  noEncoding: boolean,
  isSelectable: boolean,

  contextHashtag?: string,

  // ! recursive, do not provide
  nasty?: {
    i: number,
    usedLength: number,
    text: string,
    lastEntity?: MessageEntity
  },
  voodoo?: boolean,
  customEmojis?: Map<DocId, CustomEmojiElements>,
  customWraps?: Set<HTMLElement>,
}> & CustomEmojiRendererElementOptions = {}) {
  const fragment = document.createDocumentFragment();
  if(!text) {
    return fragment;
  }

  const nasty = options.nasty ??= {
    i: 0,
    usedLength: 0,
    text
  };

  const wrapSomething = (wrapElement: HTMLElement, noFiller?: boolean) => {
    const element = document.createElement('span');
    // element.append(BOM, a, BOM);
    if(options.wrappingDraft) {
      element.contentEditable = 'false';
    }
    // element.style.display = 'inline-block';
    element.classList.add('input-something');
    element.append(/* BOM,  */wrapElement);

    (lastElement || fragment).append(element);

    wrapElement.classList.add('input-selectable');
    // if(wrapElement instanceof HTMLImageElement) {
    //   element.prepend(f());
    // } else {
    !noFiller && wrapElement.append(createCustomFiller(true));
    // }

    customWraps.add(element);

    return element;
  };

  options.isSelectable ||= options.wrappingDraft;

  const customEmojis = options.customEmojis ??= new Map() as Map<DocId, CustomEmojiElements>;
  const customWraps = options.customWraps ??= new Set();

  const entities = options.entities ??= parseEntities(nasty.text);

  const passEntities = options.passEntities ??= {};
  const contextSite = options.contextSite ??= 'Telegram';
  const contextExternal = contextSite !== 'Telegram';

  const textLength = nasty.text.length;
  const length = entities.length;
  let lastElement: HTMLElement | DocumentFragment;
  for(; nasty.i < length; ++nasty.i) {
    let entity = entities[nasty.i];

    // * check whether text was sliced
    // TODO: consider about moving it to other function
    if(entity.offset >= textLength) {
      if(entity._ !== 'messageEntityCaret') { // * can set caret to the end
        continue;
      }
    } else if((entity.offset + entity.length) > textLength) {
      entity = copy(entity);
      // entity.length = entity.offset + entity.length - textLength;
      entity.length = textLength - entity.offset;
    }

    if(entity.length) {
      nasty.lastEntity = entity;
    }

    let nextEntity = entities[nasty.i + 1];

    const startOffset = entity.offset;
    const endOffset = startOffset + entity.length;
    const endPartOffset = Math.min(endOffset, nextEntity?.offset ?? 0xFFFF);
    const fullEntityText = nasty.text.slice(startOffset, endOffset);
    const sliced = nasty.text.slice(startOffset, endPartOffset);
    let partText = sliced;

    if(nasty.usedLength < startOffset) {
      (lastElement || fragment).append(nasty.text.slice(nasty.usedLength, startOffset));
    }

    if(lastElement) {
      lastElement = fragment;
    }

    nasty.usedLength = endPartOffset;

    let element: HTMLElement,
      property: 'textContent' | 'alt' = 'textContent',
      usedText = false;
    switch(entity._) {
      case 'messageEntityBold': {
        if(!options.noTextFormat) {
          if(options.wrappingDraft) {
            element = document.createElement('span');
            // element.style.fontWeight = 'bold';
            element.style.fontFamily = 'markup-bold';
          } else {
            element = document.createElement('strong');
          }
        }

        break;
      }

      case 'messageEntityItalic': {
        if(!options.noTextFormat) {
          if(options.wrappingDraft) {
            element = document.createElement('span');
            // element.style.fontStyle = 'italic';
            element.style.fontFamily = 'markup-italic';
          } else {
            element = document.createElement('em');
          }
        }

        break;
      }

      case 'messageEntityStrike': {
        if(options.wrappingDraft) {
          element = document.createElement('span');
          // const styleName = IS_SAFARI ? 'text-decoration' : 'text-decoration-line';
          // element.style.cssText = `${styleName}: line-through;`;
          element.style.fontFamily = 'markup-strikethrough';
        } else/*  if(!options.noTextFormat) */ {
          element = document.createElement('del');
        }

        break;
      }

      case 'messageEntityUnderline': {
        if(options.wrappingDraft) {
          element = document.createElement('span');
          // const styleName = IS_SAFARI ? 'text-decoration' : 'text-decoration-line';
          // element.style.cssText = `${styleName}: underline;`;
          element.style.fontFamily = 'markup-underline';
        } else if(!options.noTextFormat) {
          element = document.createElement('u');
        }

        break;
      }

      case 'messageEntityPre':
      case 'messageEntityCode': {
        if(options.wrappingDraft) {
          element = document.createElement('span');
          // element.style.fontFamily = 'var(--font-monospace)';
          element.style.fontFamily = 'markup-monospace';
        } else if(!options.noTextFormat) {
          element = document.createElement('code');
        }

        break;
      }

      // case 'messageEntityPre': {
      //   if(options.wrappingDraft) {
      //     element = document.createElement('span');
      //     element.style.fontFamily = 'var(--font-monospace)';
      //   } else if(!options.noTextFormat) {
      //     element = document.createElement('pre');
      //     const inner = document.createElement('code');
      //     if(entity.language) {
      //       inner.className = 'language-' + entity.language;
      //       inner.textContent = entityText;
      //       usedText = true;
      //     }
      //   }

      //   break;
      // }

      case 'messageEntityHighlight': {
        element = document.createElement('i');
        element.className = 'text-highlight';
        break;
      }

      case 'messageEntityBotCommand': {
        // if(!(options.noLinks || options.noCommands || contextExternal)/*  && !entity.unsafe */) {
        if(!options.noLinks && passEntities[entity._]) {
          let command = fullEntityText.slice(1);
          let bot: string | boolean;
          let atPos: number;
          if((atPos = command.indexOf('@')) !== -1) {
            bot = command.slice(atPos + 1);
            command = command.slice(0, atPos);
          } else {
            bot = options.fromBot;
          }

          element = document.createElement('a');
          (element as HTMLAnchorElement).href = encodeEntities('tg://bot_command?command=' + encodeURIComponent(command) + (bot ? '&bot=' + encodeURIComponent(bot) : ''));
          if(!contextExternal) {
            element.setAttribute('onclick', 'execBotCommand(this)');
          }
        }

        break;
      }

      case 'messageEntityCustomEmoji': {
        if(!IS_CUSTOM_EMOJI_SUPPORTED) {
          break;
        }

        while(nextEntity?._ === 'messageEntityEmoji' && nextEntity.offset < endOffset) {
          ++nasty.i;
          nasty.lastEntity = nextEntity;
          nasty.usedLength += nextEntity.length;
          nextEntity = entities[nasty.i + 1];
        }

        const customEmojiElement = element = CustomEmojiElement.create(entity.document_id);
        const {docId} = customEmojiElement;
        let set = customEmojis.get(docId);
        if(!set) customEmojis.set(docId, set = new Set());
        set.add(customEmojiElement);
        customEmojiElement.dataset.stickerEmoji = fullEntityText;

        if(options.wrappingDraft) {
          element = document.createElement('img');
          (element as HTMLImageElement).alt = fullEntityText;
          for(const i in customEmojiElement.dataset) {
            element.dataset[i] = customEmojiElement.dataset[i];
          }
          (element as any).customEmojiElement = customEmojiElement;
          customEmojiElement.placeholder = element as HTMLImageElement;
          element.classList.add('custom-emoji-placeholder');
          (element as HTMLImageElement).src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAAtJREFUGFdjYAACAAAFAAGq1chRAAAAAElFTkSuQmCC';
          property = 'alt';
          break;
        }

        if(options.isSelectable) {
          // const s = document.createElement('span');
          // s.append(fullEntityText);
          // element.append(s);
          // element.textContent = fullEntityText;
          // element.textContent = 'a';
          // element.contentEditable = 'false';

          // const x = f();
          // x.style.display = 'inline-block';
          // x.contentEditable = 'false';
          // (lastElement || fragment).append(BOM);
          // (lastElement || fragment).append(x);

          element = wrapSomething(element, !!options.customEmojiRenderer);

          // const a = element;
          // element = document.createElement('span');
          // element.append(BOM, a, BOM);
          // element.contentEditable = 'false';
        }

        property = 'alt';
        break;
      }

      case 'messageEntityEmoji': {
        let isSupported = IS_EMOJI_SUPPORTED;
        if(isSupported) {
          for(const version in EmojiVersions) {
            if(version) {
              const emojiData = EmojiVersions[version as EMOJI_VERSION];
              if(emojiData.hasOwnProperty(entity.unicode) && !EMOJI_VERSIONS_SUPPORTED[version as EMOJI_VERSION]) {
                isSupported = false;
                break;
              }
            }
          }
        }

        // if(!(options.wrappingDraft && isSupported)) { // * fix safari emoji
        if(!isSupported) { // no wrapping needed
          // if(isSupported) { // ! contenteditable="false" нужен для поля ввода, иначе там будет меняться шрифт в Safari, или же рендерить смайлик напрямую, без контейнера
          //   insertPart(entity, '<span class="emoji">', '</span>');
          // } else {
          element = document.createElement('img');
          (element as HTMLImageElement).src = `assets/img/emoji/${entity.unicode}.png`;
          property = 'alt';
          element.className = 'emoji';

          // if(options.isSelectable) {
          //   usedText = true;
          //   (element as HTMLImageElement).alt = partText;
          //   element = wrapSomething(element);
          // }

          // const a = element;
          // a.contentEditable = 'false';
          // element = document.createElement('span');
          // element.append(a);
          // element.contentEditable = 'false';
          // }
        // } else if(options.mustWrapEmoji) {
        } else if(!options.wrappingDraft) {
          element = document.createElement('span');
          element.className = 'emoji';
        }/*  else if(!IS_SAFARI) {
          insertPart(entity, '<span class="emoji" contenteditable="false">', '</span>');
        } */
        /* if(!isSupported) {
          insertPart(entity, `<img src="assets/img/emoji/${entity.unicode}.png" alt="`, `" class="emoji">`);
        } */

        break;
      }

      case 'messageEntityCaret': {
        element = document.createElement('span');
        element.className = 'composer-sel';
        break;
      }

      // case 'messageEntityLinebreak': {
      //   if(options.noLinebreaks) {
      //     insertPart(entity, ' ');
      //   } else {
      //     insertPart(entity, '<br/>');
      //   }

      //   break;
      // }

      case 'messageEntityUrl':
      case 'messageEntityTextUrl': {
        if(!(options.noLinks && !passEntities[entity._])) {
          // let inner: string;
          let url: string = (entity as MessageEntity.messageEntityTextUrl).url || fullEntityText;
          let masked = false;
          let onclick: string;

          const wrapped = wrapUrl(url, true);
          url = wrapped.url;
          onclick = wrapped.onclick;

          if(entity._ === 'messageEntityTextUrl') {
            if(nextEntity?._ === 'messageEntityUrl' &&
              nextEntity.length === entity.length &&
              nextEntity.offset === entity.offset) {
              nasty.lastEntity = nextEntity;
              ++nasty.i;
            }

            if(url !== fullEntityText) {
              masked = true;
            }
          } else {
            // inner = encodeEntities(replaceUrlEncodings(entityText));
          }

          const currentContext = !!onclick;
          if(!onclick && masked && !currentContext) {
            onclick = 'showMaskedAlert';
          }

          if(options.wrappingDraft) {
            onclick = undefined;
          }

          const href = (currentContext || typeof electronHelpers === 'undefined') ?
            url :
            `javascript:electronHelpers.openExternal('${url}');`;

          element = document.createElement('a');
          element.className = 'anchor-url';
          (element as HTMLAnchorElement).href = href;

          if(!(currentContext || typeof electronHelpers !== 'undefined')) {
            setBlankToAnchor(element as HTMLAnchorElement);
          }

          if(onclick) {
            element.setAttribute('onclick', onclick + '(this)');
          }
        }

        break;
      }

      case 'messageEntityEmail': {
        if(!options.noLinks) {
          element = document.createElement('a');
          (element as HTMLAnchorElement).href = encodeEntities('mailto:' + fullEntityText);
          setBlankToAnchor(element as HTMLAnchorElement);
        }

        break;
      }

      case 'messageEntityHashtag': {
        const contextUrl = !options.noLinks && SITE_HASHTAGS[contextSite];
        if(contextUrl) {
          const hashtag = fullEntityText.slice(1);
          element = document.createElement('a');
          element.className = 'anchor-hashtag';
          (element as HTMLAnchorElement).href = contextUrl.replace('{1}', encodeURIComponent(hashtag));
          if(contextExternal) {
            setBlankToAnchor(element as HTMLAnchorElement);
          } else {
            element.setAttribute('onclick', 'searchByHashtag(this)');
          }
        }

        break;
      }

      case 'messageEntityMentionName': {
        if(!(options.noLinks && !passEntities[entity._])) {
          element = document.createElement('a');
          (element as HTMLAnchorElement).href = buildURLHash('' + entity.user_id);
          element.className = 'follow';
          element.dataset.follow = '' + entity.user_id;
        }

        break;
      }

      case 'messageEntityMention': {
        // const contextUrl = !options.noLinks && siteMentions[contextSite];
        if(!options.noLinks) {
          const username = fullEntityText.slice(1);

          element = wrapTelegramUrlToAnchor('t.me/' + username);
          element.className = 'mention';

          // insertPart(entity, `<a class="mention" href="${contextUrl.replace('{1}', encodeURIComponent(username))}"${contextExternal ? ' target="_blank" rel="noopener noreferrer"' : ''}>`, '</a>');
        }

        break;
      }

      case 'messageEntitySpoiler': {
        if(options.noTextFormat) {
          const encoded = encodeSpoiler(nasty.text, entity);
          nasty.text = encoded.text;
          partText = encoded.entityText;
          if(endPartOffset !== endOffset) {
            nasty.usedLength += endOffset - endPartOffset;
          }
          let n: MessageEntity;
          for(; n = entities[nasty.i + 1], n && n.offset < endOffset;) {
            // nasty.usedLength += n.length;
            ++nasty.i;
            nasty.lastEntity = n;
            nextEntity = entities[nasty.i + 1];
          }
        } else if(options.wrappingDraft) {
          element = document.createElement('span');
          // element.style.fontFamily = 'spoiler';
          element.style.fontFamily = 'markup-spoiler';
        } else {
          const container = document.createElement('span');
          container.className = 'spoiler';
          element = document.createElement('span');
          element.className = 'spoiler-text';
          element.textContent = partText;
          usedText = true;
          container.append(element);
          fragment.append(container);

          container[`on${CLICK_EVENT_NAME}`] = (window as any).onSpoilerClick;
        }

        break;
      }

      case 'messageEntityTimestamp': {
        if(!options.maxMediaTimestamp || entity.time > options.maxMediaTimestamp) {
          break;
        }

        element = document.createElement('a');
        element.classList.add('timestamp');
        element.dataset.timestamp = '' + entity.time;
        (element as HTMLAnchorElement).href = '#';
        element.setAttribute('onclick', 'setMediaTimestamp(this)');

        if(options.maxMediaTimestamp === Infinity) {
          element.classList.add('is-disabled');
        }

        break;
      }
    }

    if(!usedText && partText) {
      if(element) {
        // @ts-ignore
        element[property] = partText;
      } else {
        (element || fragment).append(partText);
      }
    }

    if(element && !element.parentNode) {
      (lastElement || fragment).append(element);
    }

    while(nextEntity && nextEntity.offset < endOffset) {
      ++nasty.i;

      (element || fragment).append(wrapRichText(nasty.text, {
        ...options,
        voodoo: true
      }));

      nextEntity = entities[nasty.i + 1];
    }

    // if(!element?.parentNode) {
    //   (lastElement || fragment).append(element ?? partText);
    // }

    if(nasty.usedLength <= endOffset) {
      if(nasty.usedLength < endOffset) {
        (element || fragment).append(nasty.text.slice(nasty.usedLength, endOffset));
        nasty.usedLength = endOffset;
      }

      lastElement = fragment;
      nasty.lastEntity = undefined;
    } else if(entity.length > partText.length && element) {
      lastElement = element;
    } else {
      lastElement = fragment;
    }

    if(options.voodoo) {
      return fragment;
    }
  }

  if(nasty.lastEntity) {
    nasty.usedLength = nasty.lastEntity.offset + nasty.lastEntity.length;
  }

  if(nasty.usedLength < textLength) {
    (lastElement || fragment).append(nasty.text.slice(nasty.usedLength));
  }

  if((!options.wrappingDraft || options.customEmojiRenderer) && customEmojis.size) {
    let renderer = options.customEmojiRenderer;
    if(!renderer) {
      renderer = CustomEmojiRendererElement.create(options);
      fragment.prepend(renderer);
    }

    const loadPromise = renderer.add(customEmojis, options.lazyLoadQueue, options.wrappingDraft);
    options.loadPromises?.push(loadPromise);
    // recordPromise(loadPromise, 'render emojis: ' + docIds.length);
  }

  if(customWraps.size) {
    insertCustomFillers(customWraps);
  }

  return fragment;
}

export const createCustomFiller = (notFiller?: boolean) => {
  const x = document.createElement('span');
  x.classList.add(notFiller ? 'input-filler2' : 'input-filler');
  x.textContent = BOM;
  return x;
};

export function isCustomFillerNeededBySiblingNode(node: ChildNode) {
  if(
    // !node?.textContent ||
    // node.textContent.endsWith('\n') ||
    node?.textContent !== BOM ||
    (node as HTMLElement)?.getAttribute?.('contenteditable') === 'false'
  ) {
    // if(!node || (node as HTMLElement).firstElementChild || node.textContent.endsWith('\n')) {
    if(!node || node.textContent !== BOM || (node as HTMLElement).firstElementChild) {
      return 2;
    } else if(node.nodeType === node.ELEMENT_NODE) {
      return 1;
    }/*  else if(node.nodeType === node.TEXT_NODE && !node.nodeValue) {
      (node as CharacterData).insertData(0, BOM);
    } */
  }

  return 0;
}

export function insertCustomFillers(elements: Iterable<HTMLElement>) {
  const check = (element: HTMLElement, node: ChildNode, method: 'before' | 'after') => {
    const needed = isCustomFillerNeededBySiblingNode(node);
    if(needed === 2) {
      element[method](createCustomFiller());
    } else if(needed === 1) {
      node.appendChild(document.createTextNode(BOM));
    }
  };

  for(const element of elements) {
    const {previousSibling, nextSibling} = element;
    check(element, previousSibling, 'before');
    check(element, nextSibling, 'after');
  }
}

(window as any).wrapRichText = wrapRichText;
