    import { Components, Elements, Extensions, Language, Options } from '@ryusei/code';
    import { AnyFunction } from '@ryusei/light/dist/types/types';
    import * as CoreComponents from '../../components';
    import { ATTRIBUTES_EDITABLE_AREA } from '../../constants/attributes';
    import {
      CLASS_BACKGROUND,
      CLASS_BODY,
      CLASS_CONTAINER,
      CLASS_EDITOR,
      CLASS_EMPTY,
      CLASS_FOCUSED,
      CLASS_INITIALIZED,
      CLASS_LINES,
      CLASS_OVERLAY,
      CLASS_READONLY,
      CLASS_RENDERED,
      CLASS_SCROLLER,
      CLASS_TEXTAREA,
      CLASS_VIEW,
    } from '../../constants/classes';
    import {
      EVENT_BLUR,
      EVENT_CHANGED,
      EVENT_DESTROYED,
      EVENT_FOCUS,
      EVENT_MOUNT,
      EVENT_MOUNTED,
      EVENT_READONLY,
    } from '../../constants/events';
    import { EventBus } from '../../event/EventBus';
    import {
      activeElement,
      addClass,
      assert,
      assign,
      create,
      debounce,
      div,
      escapeHtml,
      focus,
      forOwn,
      hasClass,
      isFunction,
      isHTMLElement,
      isString,
      isUndefined,
      on,
      query,
      remove,
      removeClass,
      styles,
      text,
      toggleClass,
      unit,
    } from '../../utils';
    import { Renderer } from '../Renderer/Renderer';

    /**
     * The debounce duration for evaluating `focusout` of the editor.
     *
     * @since 0.1.0
     */
    const FOCUSOUT_DEBOUNCE_DURATION = 10;

    /**
     * The core class for the editor.
     *
     * @since 0.1.0
     */
    export class Editor {
      /**
       * Holds collection of editor elements.
       */
      elements: Elements;

      /**
       * Holds Component instances.
       */
      Components: Partial<Components> = {};

      /**
       * Holds Extension instances.
       */
      protected Extensions: Partial<Extensions> = {};

      /**
       * Holds options.
       */
      readonly options: Options;

      /**
       * Holds the EventBus instance.
       */
      readonly event: EventBus;

      /**
       * Holds the source element.
       */
      protected source: HTMLElement;

      /**
       * Holds the root element.
       */
      protected root: HTMLElement;

      /**
       * Holds the language object.
       */
      readonly language: Language;

      /**
       * Indicates whether the editor is readonly or not.
       */
      protected _readOnly: boolean;

      /**
       * The Editor constructor.
       *
       * @param language   - A Language object.
       * @param options    - Options.
       * @param extensions - An object with additional components.
       */
      constructor( language: Language, options: Options, extensions: Partial<Extensions> = {} ) {
        this.language = language;
        this.options  = options;
        this.event    = new EventBus();

        forOwn( CoreComponents, ( Component, name ) => {
          this.Components[ name ] = new Component( this );
        } );

        forOwn( extensions, ( Extension, name ) => {
          const value = this.options[ name.charAt( 0 ).toLowerCase() + name.slice( 1 ) ];

          if ( isUndefined( value ) || value ) {
            this.Extensions[ name ] = new Extension( this );
          }
        } );
      }

      /**
       * Initializes the editor and components.
       */
      protected mount(): void {
        const { options, event, elements } = this;

        this.listen();
        event.emit( EVENT_MOUNT, elements );

        forOwn( this.Components, Component => {
          Component.mount( elements );
        } );

        forOwn( this.Extensions, Extension => {
          Extension.mount( elements );
        } );

        event.emit( EVENT_MOUNTED, elements );

        this.readOnly = options.readOnly;

        if ( options.autofocus ) {
          this.focus();
        }
      }

      /**
       * Collects essential elements that constitute the code editor.
       */
      protected collect(): void {
        const { root } = this;
        const editor = query<HTMLDivElement>( root, `.${ CLASS_EDITOR }` );
        const lines  = query<HTMLDivElement>( root, `.${ CLASS_LINES }` );

        lines.contentEditable = 'true';

        this.elements = Object.freeze( {
          root,
          editor,
          lines,
          editable  : lines,
          view      : query<HTMLDivElement>( root, `.${ CLASS_VIEW }` ),
          body      : query<HTMLDivElement>( root, `.${ CLASS_BODY }` ),
          scroller  : query<HTMLDivElement>( root, `.${ CLASS_SCROLLER }` ),
          container : query<HTMLDivElement>( root, `.${ CLASS_CONTAINER }` ),
          input     : create( 'textarea', assign( { class: CLASS_TEXTAREA }, ATTRIBUTES_EDITABLE_AREA ), editor ),
          overlay   : div( CLASS_OVERLAY, root ),
          background: div( { class: CLASS_BACKGROUND, 'aria-hidden': true }, editor ),
        } );
      }

      /**
       * Listens to some events.
       */
      protected listen(): void {
        const { elements, elements: { root }, event } = this;
        const isFocused = this.isFocused.bind( this );

        let type: string;

        this.bind( root, 'pointerdown', () => {
          type = 'pointer';
        } );

        this.bind( elements.editor, 'click', () => {
          if ( ! isFocused() ) {
            this.focus( true );
          }
        } );

        this.bind( root, 'focusin', () => {
          if ( isFocused() && ! hasClass( root, CLASS_FOCUSED ) ) {
            addClass( root, CLASS_FOCUSED );
            event.emit( EVENT_FOCUS, type );
          }
        } );

        this.bind( root, 'focusout', debounce( () => {
          if ( ! isFocused() && hasClass( root, CLASS_FOCUSED ) ) {
            removeClass( root, CLASS_FOCUSED );
            event.emit( EVENT_BLUR );
            type = '';
          }
        }, FOCUSOUT_DEBOUNCE_DURATION ) );

        event.on( [ EVENT_MOUNTED, EVENT_CHANGED ], () => {
          toggleClass( elements.root, CLASS_EMPTY, ! this.value );
        } );
      }

      /**
       * Listens to native events.
       *
       * @param elm      - A document, a window or an element.
       * @param events   - An event name or names.
       * @param callback - A callback function.
       */
      protected bind( elm: Document | Window | Element, events: string, callback: ( e: Event ) => void ): void {
        on( elm, events, callback, this );
      }

      /**
       * Applies the editor to the target element.
       *
       * @param target - A selector to find the target element, or a target element itself.
       * @param code   - Optional. The code to overwrite the content of the target element.
       */
      apply( target: string | Element, code?: string ): void {
        const elm = isString( target ) ? query( document, target ) : target;

        if ( isHTMLElement( elm ) ) {
          this.source = elm;

          if ( hasClass( elm, CLASS_RENDERED ) ) {
            this.root = elm;
            const pre = query( elm, 'pre' );
            this.Components.Code.init( text( pre ) || '' );
            remove( pre )
          } else {
            elm.insertAdjacentHTML( 'afterend', this.html( isUndefined( code ) ? text( elm ) : code, false ) );
            styles( elm, { display: 'none' } );
            this.root = elm.nextElementSibling as HTMLElement;
          }

          addClass( this.root, CLASS_INITIALIZED );

          this.collect();
          this.mount();
        } else {
          assert( false, `${ target } is invalid.` );
        }
      }

      /**
       * Returns HTML of the editor.
       * This may not contain all lines because IE can not render tons of HTML tags at the same time.
       * The number of lines can be specified by options.
       *
       * @param code   - A code string.
       * @param source - Whether to embed the source code into the editor or not.
       *
       * @return The HTML of the editor.
       */
      html( code: string, source?: boolean ): string {
        const { Code } = this.Components;
        Code.init( code );
        return new Renderer( Code, this.event, this.options ).html( source );
      }

      /**
       * Saves the content to the source element.
       */
      save(): void {
        const { source, value } = this;

        if ( source instanceof HTMLTextAreaElement ) {
          source.value = value;
        } else {
          text( source, escapeHtml( value ) );
        }
      }

      /**
       * Focuses to the editable area.
       *
       * @param reselect - Determines whether to reselect the last position or not.
       */
      focus( reselect?: boolean ): void {
        if ( reselect ) {
          this.Components.Selection.reselect();
        } else {
          focus( this.elements.editable );
        }
      }

      /**
       * Removes the focus from the editor.
       */
      blur(): void {
        const elm = activeElement();

        if ( this.isFocused() && isHTMLElement( elm ) ) {
          elm.blur();
        }
      }

      /**
       * Attempts to invoke the method of the specified extension.
       *
       * @param name   - A name of the extension.
       * @param method - A method name to invoke.
       * @param args   - Optional. Arguments for the method.
       *
       * @return The return value of the method.
       */
      invoke<K extends keyof Extensions, P extends keyof Extensions[ K ], V extends Extensions[ K ][ P ]>(
        name: K,
        method: P,
        ...args: V extends AnyFunction ? Parameters<V> : any[]
      ): V extends AnyFunction ? ReturnType<V> : void {
        const extension = this.Extensions[ name ];

        if ( extension && isFunction( extension[ method ] ) ) {
          return extension[ method ]( ...args );
        }
      }

      /**
       * Returns the extension of the specified name.
       *
       * @param name - A name of an extension.
       *
       * @return An extension if found, or otherwise `undefined`.
       */
      require<K extends keyof Extensions>( name: K ): Extensions[ K ] | undefined {
        return this.Extensions[ name ];
      }

      /**
       * Checks if the editor is focused or not.
       *
       * @return `true` if the editor is focused, or otherwise `false`.
       */
      isFocused(): boolean {
        return this.root.contains( activeElement() );
      }

      /**
       * Destroys the editor.
       */
      destroy(): void {
        const { event } = this;

        this.save();

        forOwn( assign( this.Components, this.Extensions ), Component => {
          Component.destroy();
        } );

        delete this.Components;
        delete this.Extensions;

        styles( this.source, { display: '' } );
        remove( this.elements.root );

        event.emit( EVENT_DESTROYED );
        event.destroy();
      }

      /**
       * Sets a new value to the editor.
       *
       * @param value - A value to set.
       */
      set value( value: string ) {
        const { Components, Components: { Code } } = this;
        Code.value = value;

        Components.View.jump( 0 );
        Components.Sync.sync( 0, Code.size - 1 );
        Components.Selection.set( [ 0, 0 ] );

        this.event.emit( 'refresh' );
      }

      /**
       * Returns the current value of the editor.
       *
       * @return The current value.
       */
      get value(): string {
        return this.Components.Code.value;
      }

      /**
       * Sets the width of the root element.
       *
       * @param width - Width to set as pixel or CSS styles.
       */
      set width( width: number | string ) {
        styles( this.root, { width: unit( width ) } );
        this.Components.View.emitResize();
      }

      /**
       * Returns the width of the editor in pixel.
       *
       * @return The width of the editor.
       */
      get width(): number {
        return this.root.clientWidth;
      }

      /**
       * Sets the height of the root element.
       *
       * @param height - Height to set as pixel or CSS styles.
       */
      set height( height: number | string ) {
        styles( this.root, { height: unit( height ) } );
        this.Components.View.emitResize();
      }

      /**
       * Returns the height of the editor in pixel.
       *
       * @return The height of the editor.
       */
      get height(): number {
        return this.root.clientHeight;
      }

      /**
       * Makes the editor mutable or immutable.
       *
       * @param readOnly - Whether to make the editor immutable or mutable.
       */
      set readOnly( readOnly: boolean ) {
        const { elements } = this;
        const { editable } = elements;

        editable.contentEditable = readOnly ? 'false' : 'true';

        this.Components.Input.readOnly = readOnly;
        this._readOnly = readOnly;

        toggleClass( elements.root, CLASS_READONLY, readOnly );

        this.event.emit( EVENT_READONLY, readOnly );
      }

      /**
       * Indicates whether the editor is disabled or not.
       *
       * @return - `true` if the input is read-only or `false` if not.
       */
      get readOnly(): boolean {
        return this._readOnly;
      }
    }