/*
 * The MIT License (MIT)
 *
 * Copyright (c) 2015 - present Instructure, Inc.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

import { Component } from 'react'
import { deepEqual as isEqual } from '@instructure/ui-utils'

import { EditorSelection, EditorState, StateEffect } from '@codemirror/state'
import type { Transaction, TransactionSpec } from '@codemirror/state'
import {
  EditorView,
  highlightSpecialChars,
  highlightActiveLine,
  drawSelection,
  dropCursor,
  rectangularSelection,
  crosshairCursor,
  lineNumbers,
  highlightActiveLineGutter,
  keymap
} from '@codemirror/view'
import type { KeyBinding } from '@codemirror/view'
import {
  autocompletion,
  completionKeymap,
  closeBrackets,
  closeBracketsKeymap
} from '@codemirror/autocomplete'
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search'
import {
  indentSelection,
  defaultKeymap,
  indentWithTab,
  history,
  historyKeymap
} from '@codemirror/commands'
import { lintKeymap } from '@codemirror/lint'
import {
  indentOnInput,
  indentRange,
  indentUnit,
  StreamLanguage,
  bracketMatching,
  foldGutter,
  foldKeymap,
  defaultHighlightStyle,
  syntaxHighlighting,
  HighlightStyle
} from '@codemirror/language'
import { javascript } from '@codemirror/lang-javascript'
import { html } from '@codemirror/lang-html'
import { css } from '@codemirror/lang-css'
import { markdown } from '@codemirror/lang-markdown'
import { json } from '@codemirror/lang-json'
import { shell } from '@codemirror/legacy-modes/mode/shell'
import { yaml } from '@codemirror/legacy-modes/mode/yaml'
// import { oneDarkTheme, oneDarkHighlightStyle } from '@codemirror/theme-one-dark'

import {
  omitProps,
  passthroughProps,
  withDeterministicId
} from '@instructure/ui-react-utils'
import { requestAnimationFrame } from '@instructure/ui-dom-utils'
import type { RequestAnimationFrameType } from '@instructure/ui-dom-utils'

import { ScreenReaderContent } from '@instructure/ui-a11y-content'
import { textDirectionContextConsumer } from '@instructure/ui-i18n'

import { withStyleLegacy as withStyle } from '@instructure/emotion'

import customSearch from './SearchPanel'

import generateStyle from './styles'
import generateComponentTheme from './theme'

import { rtlHorizontalArrowKeymap } from './customKeybinding'

import { allowedProps } from './props'
import type { SourceCodeEditorProps } from './props'

/**
---
category: components
---
**/
@withDeterministicId()
@withStyle(generateStyle, generateComponentTheme)
@textDirectionContextConsumer()
class SourceCodeEditor extends Component<SourceCodeEditorProps> {
  static readonly componentId = 'SourceCodeEditor'

  static allowedProps = allowedProps
  static defaultProps = {
    language: 'jsx',
    readOnly: false,
    editable: true,
    lineNumbers: false,
    foldGutter: false,
    highlightActiveLine: false,
    highlightActiveLineGutter: false,
    lineWrapping: false,
    autofocus: false,
    spellcheck: false,
    rtlMoveVisually: true,
    indentOnLoad: false,
    indentWithTab: false,
    defaultValue: '',
    height: 'auto'
  }

  private readonly _id: string

  ref: HTMLDivElement | null = null

  private _containerRef?: HTMLDivElement
  private _editorView?: EditorView

  private _raf: RequestAnimationFrameType[] = []

  private _newSelectionAfterValueChange?: EditorSelection

  handleRef = (el: HTMLDivElement | null) => {
    const { elementRef } = this.props

    this.ref = el

    if (typeof elementRef === 'function') {
      elementRef(el)
    }
  }

  handleContainerRef = (el: HTMLDivElement | null) => {
    const { containerRef } = this.props

    this._containerRef = el || undefined

    if (typeof containerRef === 'function') {
      containerRef(el)
    }
  }

  private addAnimationFrame(callback?: FrameRequestCallback) {
    if (typeof callback === 'function') {
      this._raf.push(requestAnimationFrame(callback))
    }
  }

  private cancelAnimationFrames() {
    this._raf.forEach((request) => request.cancel())
    this._raf = []
  }

  public focus() {
    this.addAnimationFrame(() => {
      this._editorView?.focus()
    })
  }

  public get hasFocus() {
    return this._editorView?.hasFocus
  }

  public selectAll() {
    if (this._editorView) {
      this.addAnimationFrame(() => {
        this.dispatchViewSelection({
          anchor: 0,
          head: this.currentDocValue?.length
        })
      })
    }
  }

  public deselectAll() {
    if (this._editorView) {
      this.addAnimationFrame(() => {
        this.dispatchViewSelection({
          anchor: 0,
          head: 0
        })
      })
    }
  }

  public indentCurrentSelection() {
    this.addAnimationFrame(() => {
      if (this._editorView) {
        indentSelection({
          state: this._editorView.state,
          dispatch: (transaction) => {
            this._editorView?.update([transaction])
          }
        })
      }
    })
  }

  public indentAll() {
    this.addAnimationFrame(() => {
      if (this._editorView && this.currentDocValue) {
        this.indentCodeRange(0, this.currentDocValue.length)
      }
    })
  }

  private indentCodeRange(from: number, to: number) {
    this.addAnimationFrame(() => {
      if (this._editorView && this.currentDocValue) {
        this.dispatchViewChanges({
          changes: indentRange(this._editorView.state, from, to)
        })
      }
    })
  }

  // Attach state effects
  private dispatchViewEffects(effects?: TransactionSpec['effects']) {
    if (!this._editorView || !effects) return

    this._editorView.dispatch({ effects })
  }

  // Dispatch changes to the document
  private dispatchViewChanges({
    changes,
    selection,
    userEvent
  }: TransactionSpec) {
    if (!this._editorView || !changes) return

    this._editorView.dispatch({
      changes,
      ...(selection ? { selection } : undefined),
      ...(userEvent ? { userEvent } : undefined)
    })
  }

  // Select a portion of the document
  private dispatchViewSelection(selection?: TransactionSpec['selection']) {
    if (!this._editorView || !selection) return

    this._editorView.dispatch({ selection })
  }

  get currentDocValue() {
    return this._editorView?.state.doc
  }

  // when value is passed, the editor should be controlled
  get isControlled() {
    return typeof this.props.value === 'string'
  }

  constructor(props: SourceCodeEditorProps) {
    super(props)
    this._id = props.deterministicId!()
  }

  componentDidMount() {
    const { value, defaultValue, autofocus, indentOnLoad } = this.props

    this.props.makeStyles?.()

    const state = EditorState.create({
      doc: value || defaultValue,
      extensions: this.extensions
    })
    this._editorView = new EditorView({
      state,
      parent: this._containerRef
    })

    // from the a11y team:
    // axe devtools and other automated a11y tests both flagging this issue,
    // which can be observed while navigating with keyboard:
    // Ensure elements that have scrollable content are accessible by keyboard
    // To solve this problem, you need to fix at least (1) of the following:
    // Element should have focusable content, Element should be focusable
    this._editorView.scrollDOM.tabIndex = 0

    if (autofocus) {
      this.focus()
    }

    if (indentOnLoad) {
      this.indentAll()
    }
    this.assignAriaLabel()
  }

  componentWillUnmount() {
    this._editorView?.destroy()

    this.cancelAnimationFrames()
  }

  componentDidUpdate(prevProps: SourceCodeEditorProps) {
    this.props.makeStyles?.()

    if (this._editorView) {
      if (this.props.value !== prevProps.value) {
        this.refreshEditorValue()
      }

      if (this.shouldUpdateExtensions(prevProps)) {
        this.refreshExtensions()
      }
    }
  }

  private shouldUpdateExtensions(prevProps: SourceCodeEditorProps) {
    const propsToObserve: (keyof SourceCodeEditorProps)[] = [
      'styles', // needed for theme update
      'themeOverride',
      'language',
      'readOnly',
      'editable',
      'lineNumbers',
      'highlightActiveLineGutter',
      'foldGutter',
      'lineWrapping',
      'autofocus',
      'spellcheck',
      'direction',
      'dir',
      'rtlMoveVisually',
      'indentOnLoad',
      'indentWithTab',
      'indentUnit',
      'highlightActiveLine',
      'attachment'
    ]

    for (const prop of propsToObserve) {
      if (!isEqual(this.props[prop], prevProps[prop])) {
        return true
      }
    }

    return false
  }

  get direction() {
    // comes from the `direction` prop and
    // falls back to the `dir` prop coming from the bidirectional decorator
    return this.props.direction || this.props.dir
  }

  get extensions() {
    const extensions = [
      ...this.baseExtensions,

      // our custom extensions
      this.languageExtension,
      this.onChangeExtension,
      this.focusListenerExtension,
      this.announceLineNumberExtension
    ]

    if (this.themeExtension) {
      extensions.push(this.themeExtension)
    }
    if (this.props.lineNumbers) {
      extensions.push(lineNumbers())
    }
    if (this.props.highlightActiveLine) {
      extensions.push(highlightActiveLine())
    }
    if (this.props.highlightActiveLineGutter) {
      extensions.push(highlightActiveLineGutter())
    }
    if (this.props.foldGutter) {
      extensions.push(foldGutter())
    }
    if (this.props.lineWrapping) {
      extensions.push(EditorView.lineWrapping)
    }
    if (this.props.editable === false) {
      extensions.push(EditorView.editable.of(false))
    }
    if (this.props.readOnly) {
      extensions.push(EditorState.readOnly.of(true))
    }
    if (this.props.spellcheck) {
      extensions.push(EditorView.contentAttributes.of({ spellcheck: 'true' }))
    }
    if (this.direction) {
      extensions.push(
        EditorView.contentAttributes.of({
          dir: this.direction
        })
      )
    }
    if (this.props.indentUnit) {
      extensions.push(indentUnit.of(this.props.indentUnit))
    }

    return extensions
  }

  get baseExtensions() {
    return [
      // The extensions are based on codemirrors basic setup from 'codemirror'.
      // It is recommended by CodeMirror, that if we want to configure
      // our editor more precisely, we have to copy the source
      // and adjust it as desired.
      highlightSpecialChars(),
      history(),
      drawSelection(),
      dropCursor(),
      EditorState.allowMultipleSelections.of(true),
      syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
      bracketMatching(),
      closeBrackets(),
      autocompletion(),
      rectangularSelection(),
      crosshairCursor(),
      highlightSelectionMatches(),
      indentOnInput(),
      customSearch(this.props.searchConfig),
      keymap.of(this.keymaps)
    ]
  }

  get keymaps(): KeyBinding[] {
    // TODO: if more keymaps are added, list them in the docs as well (#Command keybinding)
    const keymaps: KeyBinding[] = [
      ...closeBracketsKeymap,
      ...this.commandKeybinding,
      ...historyKeymap,
      ...foldKeymap,
      ...completionKeymap,
      ...lintKeymap,
      ...(this.props.searchConfig ? searchKeymap : [])
    ]

    if (this.props.indentWithTab) {
      keymaps.push(indentWithTab)
    }

    return keymaps
  }

  get commandKeybinding() {
    const { rtlMoveVisually } = this.props

    if (this.direction === 'rtl' && !rtlMoveVisually) {
      const overrideableKeys = rtlHorizontalArrowKeymap.map((binding) =>
        binding.key ? binding.key : binding.mac ? binding.mac : binding
      )
      // we have to remove the binding we want to override from the original,
      // otherwise all will be merged and the defaults will still apply
      const filteredOriginal = defaultKeymap.filter((binding) =>
        binding.key
          ? overrideableKeys.indexOf(binding.key) < 0
          : binding.mac
          ? overrideableKeys.indexOf(binding.mac) < 0
          : false
      )
      return [...filteredOriginal, ...rtlHorizontalArrowKeymap]
    }

    return defaultKeymap
  }

  get themeExtension() {
    const { styles } = this.props

    if (!styles?.theme || !styles.highlightStyle) {
      return undefined
    }

    const theme = EditorView.theme(styles?.theme)
    const highlightStyle = syntaxHighlighting(
      HighlightStyle.define(styles?.highlightStyle)
    )

    // see notes in props.ts
    // if (darkTheme) {
    //   theme = oneDarkTheme
    //   highlightStyle = syntaxHighlighting(oneDarkHighlightStyle)
    // }

    return [theme, highlightStyle]
  }

  get languageExtension() {
    const { language } = this.props

    switch (language) {
      case 'json':
        return json()
      case 'js':
      case 'jsx':
      case 'javascript':
        return javascript({ jsx: true, typescript: true })
      case 'html':
        return html({ matchClosingTags: true, autoCloseTags: true })
      case 'css':
        return css()
      case 'markdown':
        return markdown()
      case 'sh':
      case 'shell':
      case 'bash': // ????
        return StreamLanguage.define(shell)
      case 'yml':
      case 'yaml':
        return StreamLanguage.define(yaml)
      default:
        return javascript({ jsx: true, typescript: true })
    }
  }

  callOnChangeHandler(newValue: string) {
    const { onChange, value } = this.props

    this.addAnimationFrame(() => {
      if (typeof onChange === 'function' && newValue !== value) {
        onChange(newValue)
      }
    })
  }

  get onChangeExtension() {
    return EditorState.changeFilter.of((transaction: Transaction) => {
      if (!this._editorView) {
        return false
      }

      if (transaction.docChanged) {
        const newDoc = transaction.newDoc.toString()

        if (this.isControlled) {
          // the value will be changed by the onChange handler,
          // refreshEditorValue has to run first
          if (newDoc !== this.props.value) {
            this._newSelectionAfterValueChange = transaction.selection
            this.cancelAnimationFrames()
            this.callOnChangeHandler(newDoc)
            return false
          } else {
            return true
          }
        } else {
          this.callOnChangeHandler(newDoc)
        }
      }

      return true
    })
  }

  get focusListenerExtension() {
    const { onFocus, onBlur } = this.props

    return EditorView.updateListener.of((update) => {
      if (update.focusChanged && this._editorView) {
        if (this.hasFocus) {
          if (typeof onFocus === 'function') {
            onFocus()
          }
        } else {
          if (typeof onBlur === 'function') {
            onBlur()
          }
        }
      }
    })
  }

  get announceLineNumberExtension() {
    return EditorState.transactionExtender.of((tr) => {
      const selection = tr.selection
      const oldSelection = tr.startState.selection.main

      if (selection && selection.main.empty && oldSelection.empty) {
        const oldLine = tr.startState.doc.lineAt(oldSelection.head)
        const newLine = tr.newDoc.lineAt(selection.main.head)
        if (oldLine.number != newLine.number)
          return {
            effects: EditorView.announce.of(
              tr.startState.phrase('line ') + newLine.number + '.'
            )
          }
      }
      return null
    })
  }

  refreshExtensions() {
    this.dispatchViewEffects(StateEffect.reconfigure.of(this.extensions))
  }

  refreshEditorValue() {
    if (!this._editorView) return

    const { value } = this.props

    const currentValue = this._editorView.state.doc!.toString()

    if (typeof value !== 'undefined' && currentValue !== value) {
      let userEvent
      const lengthDiff = value.length - currentValue.length

      // setting user events are needed for the autocomplete to work
      // (only these 2 events, autocomplete doesn't work on paste, etc.)
      if (lengthDiff === 1) {
        userEvent = 'input.type'
      } else if (lengthDiff === -1) {
        userEvent = 'delete.backward'
      }

      this.dispatchViewChanges({
        changes: {
          from: 0,
          to: currentValue.length,
          insert: value || ''
        },
        selection: this._newSelectionAfterValueChange,
        userEvent: userEvent
      })
      this._newSelectionAfterValueChange = undefined
    }

    if (this.props.indentOnLoad) {
      this.indentAll()
    }
  }

  assignAriaLabel = () => {
    if (this._containerRef) {
      const editorDiv = this._containerRef.querySelector('[role="textbox"]')
      if (editorDiv) {
        editorDiv.setAttribute('aria-labelledby', `${this._id}`)
      }
    }
  }

  render() {
    const { label, styles, ...restProps } = this.props

    return (
      <div
        data-cid="SourceCodeEditor"
        ref={this.handleRef}
        css={styles?.codeEditor}
        {...passthroughProps(
          omitProps(restProps, SourceCodeEditor.allowedProps)
        )}
      >
        <label css={styles?.label} id={this._id}>
          <ScreenReaderContent>{label}</ScreenReaderContent>
          <div
            ref={this.handleContainerRef}
            css={styles?.codeEditorContainer}
          />
        </label>
      </div>
    )
  }
}

export default SourceCodeEditor
export { SourceCodeEditor }
