/* eslint-env browser */

/**
 * Web components.
 *
 * @module component
 */

import * as dom from './dom.js'
import * as diff from './diff.js'
import * as object from './object.js'
import * as json from './json.js'
import * as string from './string.js'
import * as array from './array.js'
import * as number from './number.js'
import * as func from './function.js'

/**
 * @type {CustomElementRegistry}
 */
export const registry = customElements

/**
 * @param {string} name
 * @param {any} constr
 * @param {ElementDefinitionOptions} [opts]
 */
export const define = (name, constr, opts) => registry.define(name, constr, opts)

/**
 * @param {string} name
 * @return {Promise<CustomElementConstructor>}
 */
export const whenDefined = name => registry.whenDefined(name)

const upgradedEventName = 'upgraded'
const connectedEventName = 'connected'
const disconnectedEventName = 'disconnected'

/**
 * @template S
 */
export class Lib0Component extends HTMLElement {
  /**
   * @param {S} [state]
   */
  constructor (state) {
    super()
    /**
     * @type {S|null}
     */
    this.state = /** @type {any} */ (state)
    /**
     * @type {any}
     */
    this._internal = {}
  }

  /**
   * @param {S} state
   * @param {boolean} [forceStateUpdate] Force that the state is rerendered even if state didn't change
   */
  setState (state, forceStateUpdate = true) {}
  /**
    * @param {any} stateUpdate
    */
  updateState (stateUpdate) { }
}

/**
 * @param {any} val
 * @param {"json"|"string"|"number"} type
 * @return {string}
 */
const encodeAttrVal = (val, type) => {
  if (type === 'json') {
    val = json.stringify(val)
  }
  return val + ''
}

/**
 * @param {any} val
 * @param {"json"|"string"|"number"|"bool"} type
 * @return {any}
 */
const parseAttrVal = (val, type) => {
  switch (type) {
    case 'json':
      return json.parse(val)
    case 'number':
      return Number.parseFloat(val)
    case 'string':
      return val
    case 'bool':
      return val != null
    default:
      return null
  }
}

/**
 * @typedef {Object} CONF
 * @property {string?} [CONF.template] Template for the shadow dom.
 * @property {string} [CONF.style] shadow dom style. Is only used when
 * `CONF.template` is defined
 * @property {S} [CONF.state] Initial component state.
 * @property {function(S,S|null,Lib0Component<S>):void} [CONF.onStateChange] Called when
 * the state changes.
 * @property {Object<string,function(any, any):Object>} [CONF.childStates] maps from
 * CSS-selector to transformer function. The first element that matches the
 * CSS-selector receives state updates via the transformer function.
 * @property {Object<string,"json"|"number"|"string"|"bool">} [CONF.attrs]
 * attrs-keys and state-keys should be camelCase, but the DOM uses kebap-case. I.e.
 * `attrs = { myAttr: 4 }` is represeted as `<my-elem my-attr="4" />` in the DOM
 * @property {Object<string, function(CustomEvent, Lib0Component<any>):boolean|void>} [CONF.listeners] Maps from dom-event-name
 * to event listener.
 * @property {function(S, S, Lib0Component<S>):Object<string,string>} [CONF.slots] Fill slots
 * automatically when state changes. Maps from slot-name to slot-html.
 * @template S
 */

/**
 * @template T
 * @param {string} name
 * @param {CONF<T>} cnf
 * @return {typeof Lib0Component}
 */
export const createComponent = (name, { template, style = '', state: defaultState, onStateChange = () => {}, childStates = { }, attrs = {}, listeners = {}, slots = () => ({}) }) => {
  /**
   * Maps from camelCase attribute name to kebap-case attribute name.
   * @type {Object<string,string>}
   */
  const normalizedAttrs = {}
  for (const key in attrs) {
    normalizedAttrs[string.fromCamelCase(key, '-')] = key
  }
  const templateElement = template ? /** @type {HTMLTemplateElement} */ (dom.parseElement(`
    <template>
      <style>${style}</style>
      ${template}
    </template>
  `)) : null

  class _Lib0Component extends HTMLElement {
    /**
     * @param {T} [state]
     */
    constructor (state) {
      super()
      /**
       * @type {Array<{d:Lib0Component<T>, s:function(any, any):Object}>}
       */
      this._childStates = []
      /**
       * @type {Object<string,string>}
       */
      this._slots = {}
      this._init = false
      /**
       * @type {any}
       */
      this._internal = {}
      /**
       * @type {any}
       */
      this.state = state || null
      this.connected = false
      // init shadow dom
      if (templateElement) {
        const shadow = /** @type {ShadowRoot} */ (this.attachShadow({ mode: 'open' }))
        shadow.appendChild(templateElement.content.cloneNode(true))
        // fill child states
        for (const key in childStates) {
          this._childStates.push({
            d: /** @type {Lib0Component<T>} */ (dom.querySelector(/** @type {any} */ (shadow), key)),
            s: childStates[key]
          })
        }
      }
      dom.emitCustomEvent(this, upgradedEventName, { bubbles: true })
    }

    connectedCallback () {
      this.connected = true
      if (!this._init) {
        this._init = true
        const shadow = this.shadowRoot
        if (shadow) {
          dom.addEventListener(shadow, upgradedEventName, event => {
            this.setState(this.state, true)
            event.stopPropagation()
          })
        }
        /**
         * @type {Object<string, any>}
         */
        const startState = this.state || object.assign({}, defaultState)
        if (attrs) {
          for (const key in attrs) {
            const normalizedKey = string.fromCamelCase(key, '-')
            const val = parseAttrVal(this.getAttribute(normalizedKey), attrs[key])
            if (val) {
              startState[key] = val
            }
          }
        }
        // add event listeners
        for (const key in listeners) {
          dom.addEventListener(shadow || this, key, event => {
            if (listeners[key](/** @type {CustomEvent} */ (event), this) !== false) {
              event.stopPropagation()
              event.preventDefault()
              return false
            }
          })
        }
        // first setState call
        this.state = null
        this.setState(startState)
      }
      dom.emitCustomEvent(/** @type {any} */ (this.shadowRoot || this), connectedEventName, { bubbles: true })
    }

    disconnectedCallback () {
      this.connected = false
      dom.emitCustomEvent(/** @type {any} */ (this.shadowRoot || this), disconnectedEventName, { bubbles: true })
      this.setState(null)
    }

    static get observedAttributes () {
      return object.keys(normalizedAttrs)
    }

    /**
     * @param {string} name
     * @param {string} oldVal
     * @param {string} newVal
     *
     * @private
     */
    attributeChangedCallback (name, oldVal, newVal) {
      const curState = /** @type {any} */ (this.state)
      const camelAttrName = normalizedAttrs[name]
      const type = attrs[camelAttrName]
      const parsedVal = parseAttrVal(newVal, type)
      if (curState && (type !== 'json' || json.stringify(curState[camelAttrName]) !== newVal) && curState[camelAttrName] !== parsedVal && !number.isNaN(parsedVal)) {
        this.updateState({ [camelAttrName]: parsedVal })
      }
    }

    /**
     * @param {any} stateUpdate
     */
    updateState (stateUpdate) {
      this.setState(object.assign({}, this.state, stateUpdate))
    }

    /**
     * @param {any} state
     */
    setState (state, forceStateUpdates = false) {
      const prevState = this.state
      this.state = state
      if (this._init && (!func.equalityFlat(state, prevState) || forceStateUpdates)) {
        // fill slots
        if (state) {
          const slotElems = slots(state, prevState, this)
          for (const key in slotElems) {
            const slotContent = slotElems[key]
            if (this._slots[key] !== slotContent) {
              this._slots[key] = slotContent
              const currentSlots = /** @type {Array<any>} */ (key !== 'default' ? array.from(dom.querySelectorAll(this, `[slot="${key}"]`)) : array.from(this.childNodes).filter(/** @param {any} child */ child => !dom.checkNodeType(child, dom.ELEMENT_NODE) || !child.hasAttribute('slot')))
              currentSlots.slice(1).map(dom.remove)
              const nextSlot = dom.parseFragment(slotContent)
              if (key !== 'default') {
                array.from(nextSlot.children).forEach(c => c.setAttribute('slot', key))
              }
              if (currentSlots.length > 0) {
                dom.replaceWith(currentSlots[0], nextSlot)
              } else {
                dom.appendChild(this, nextSlot)
              }
            }
          }
        }
        onStateChange(state, prevState, this)
        if (state != null) {
          this._childStates.forEach(cnf => {
            const d = cnf.d
            if (d.updateState) {
              d.updateState(cnf.s(state, this))
            }
          })
        }
        for (const key in attrs) {
          const normalizedKey = string.fromCamelCase(key, '-')
          if (state == null) {
            this.removeAttribute(normalizedKey)
          } else {
            const stateVal = state[key]
            const attrsType = attrs[key]
            if (!prevState || prevState[key] !== stateVal) {
              if (attrsType === 'bool') {
                if (stateVal) {
                  this.setAttribute(normalizedKey, '')
                } else {
                  this.removeAttribute(normalizedKey)
                }
              } else if (stateVal == null && (attrsType === 'string' || attrsType === 'number')) {
                this.removeAttribute(normalizedKey)
              } else {
                this.setAttribute(normalizedKey, encodeAttrVal(stateVal, attrsType))
              }
            }
          }
        }
      }
    }
  }
  define(name, _Lib0Component)
  // @ts-ignore
  return _Lib0Component
}

/**
 * @param {function} definer function that defines a component when executed
 */
export const createComponentDefiner = definer => {
  /**
   * @type {any}
   */
  let defined = null
  return () => {
    if (!defined) {
      defined = definer()
    }
    return defined
  }
}

export const defineListComponent = createComponentDefiner(() => {
  const ListItem = createComponent('lib0-list-item', {
    template: '<slot name="content"></slot>',
    slots: state => ({
      content: `<div>${state}</div>`
    })
  })
  return createComponent('lib0-list', {
    state: { list: /** @type {Array<string>} */ ([]), Item: ListItem },
    onStateChange: (state, prevState, component) => {
      if (state == null) {
        return
      }
      const { list = /** @type {Array<any>} */ ([]), Item = ListItem } = state
      // @todo deep compare here by providing another parameter to simpleDiffArray
      let { index, remove, insert } = diff.simpleDiffArray(prevState ? prevState.list : [], list, func.equalityFlat)
      if (remove === 0 && insert.length === 0) {
        return
      }
      let child = /** @type {Lib0Component<any>} */ (component.firstChild)
      while (index-- > 0) {
        child = /** @type {Lib0Component<any>} */ (child.nextElementSibling)
      }
      let insertStart = 0
      while (insertStart < insert.length && remove-- > 0) {
        // update existing state
        child.setState(insert[insertStart++])
        child = /** @type {Lib0Component<any>} */ (child.nextElementSibling)
      }
      while (remove-- > 0) {
        // remove remaining
        const prevChild = child
        child = /** @type {Lib0Component<any>} */ (child.nextElementSibling)
        component.removeChild(prevChild)
      }
      // insert remaining
      component.insertBefore(dom.fragment(insert.slice(insertStart).map(insState => {
        const el = new Item()
        el.setState(insState)
        return el
      })), child)
    }
  })
})

export const defineLazyLoadingComponent = createComponentDefiner(() => createComponent('lib0-lazy', {
  state: /** @type {{component:null|String,import:null|function():Promise<any>,state:null|object}} */ ({
    component: null, import: null, state: null
  }),
  attrs: {
    component: 'string'
  },
  onStateChange: ({ component, state, import: getImport }, prevState, componentEl) => {
    if (component !== null) {
      if (getImport) {
        getImport()
      }
      if (!prevState || component !== prevState.component) {
        const el = /** @type {any} */ (dom.createElement(component))
        componentEl.innerHTML = ''
        componentEl.insertBefore(el, null)
      }
      const el = /** @type {any} */ (componentEl.firstElementChild)
      // @todo generalize setting state and check if setState is defined
      if (el.setState) {
        el.setState(state)
      }
    }
  }
}))
