import React from 'react'

import {
  ATTRIBUTE_NAMES,
  HELMET_ATTRIBUTE,
  HELMET_PROPS,
  HTML_TAG_MAP,
  REACT_TAG_MAP,
  SELF_CLOSING_TAGS,
  TAG_NAMES,
  TAG_PROPERTIES
} from './HelmetConstants'

const encodeSpecialCharacters = (str, encode = true) => {
  if (encode === false) {
    return String(str)
  }

  return String(str)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;')
}

const getTitleFromPropsList = propsList => {
  const innermostTitle = getInnermostProperty(propsList, TAG_NAMES.TITLE)
  const innermostTemplate = getInnermostProperty(
    propsList,
    HELMET_PROPS.TITLE_TEMPLATE
  )

  if (innermostTemplate && innermostTitle) {
    // use function arg to avoid need to escape $ characters
    return innermostTemplate.replace(
      /%s/g,
      () =>
        Array.isArray(innermostTitle)
          ? innermostTitle.join('')
          : innermostTitle
    )
  }

  const innermostDefaultTitle = getInnermostProperty(
    propsList,
    HELMET_PROPS.DEFAULT_TITLE
  )

  return innermostTitle || innermostDefaultTitle || undefined
}

const getOnChangeClientState = propsList => {
  return (
    getInnermostProperty(propsList, HELMET_PROPS.ON_CHANGE_CLIENT_STATE) ||
    (() => {})
  )
}

const getAttributesFromPropsList = (tagType, propsList) => {
  return propsList
    .filter(props => typeof props[tagType] !== 'undefined')
    .map(props => props[tagType])
    .reduce((tagAttrs, current) => {
      return { ...tagAttrs, ...current }
    }, {})
}

const getBaseTagFromPropsList = (primaryAttributes, propsList) => {
  return propsList
    .filter(props => typeof props[TAG_NAMES.BASE] !== 'undefined')
    .map(props => props[TAG_NAMES.BASE])
    .reverse()
    .reduce((innermostBaseTag, tag) => {
      if (!innermostBaseTag.length) {
        const keys = Object.keys(tag)

        for (let i = 0; i < keys.length; i++) {
          const attributeKey = keys[i]
          const lowerCaseAttributeKey = attributeKey.toLowerCase()

          if (
            primaryAttributes.indexOf(lowerCaseAttributeKey) !==
                        -1 &&
                    tag[lowerCaseAttributeKey]
          ) {
            return innermostBaseTag.concat(tag)
          }
        }
      }

      return innermostBaseTag
    }, [])
}

const getTagsFromPropsList = (tagName, primaryAttributes, propsList) => {
// Calculate list of tags, giving priority innermost component (end of the propslist)
  const approvedSeenTags = {}

  return propsList
    .filter(props => {
      if (Array.isArray(props[tagName])) {
        return true
      }
      if (typeof props[tagName] !== 'undefined') {
        warn(
          `Helmet: ${tagName} should be of type "Array". Instead found type "${typeof props[
            tagName
          ]}"`
        )
      }
      return false
    })
    .map(props => props[tagName])
    .reverse()
    .reduce((approvedTags, instanceTags) => {
      const instanceSeenTags = {}

      instanceTags
        .filter(tag => {
          let primaryAttributeKey
          const keys = Object.keys(tag)
          for (let i = 0; i < keys.length; i++) {
            const attributeKey = keys[i]
            const lowerCaseAttributeKey = attributeKey.toLowerCase()

            // Special rule with link tags, since rel and href are both primary tags, rel takes priority
            if (
              primaryAttributes.indexOf(lowerCaseAttributeKey) !==
                            -1 &&
                        !(
                          primaryAttributeKey === TAG_PROPERTIES.REL &&
                            tag[primaryAttributeKey].toLowerCase() ===
                                'canonical'
                        ) &&
                        !(
                          lowerCaseAttributeKey === TAG_PROPERTIES.REL &&
                            tag[lowerCaseAttributeKey].toLowerCase() ===
                                'stylesheet'
                        )
            ) {
              primaryAttributeKey = lowerCaseAttributeKey
            }
            // Special case for innerHTML which doesn't work lowercased
            if (
              primaryAttributes.indexOf(attributeKey) !== -1 &&
                        (attributeKey === TAG_PROPERTIES.INNER_HTML ||
                            attributeKey === TAG_PROPERTIES.CSS_TEXT ||
                            attributeKey === TAG_PROPERTIES.ITEM_PROP)
            ) {
              primaryAttributeKey = attributeKey
            }
          }

          if (!primaryAttributeKey || !tag[primaryAttributeKey]) {
            return false
          }

          const value = tag[primaryAttributeKey].toLowerCase()

          if (!approvedSeenTags[primaryAttributeKey]) {
            approvedSeenTags[primaryAttributeKey] = {}
          }

          if (!instanceSeenTags[primaryAttributeKey]) {
            instanceSeenTags[primaryAttributeKey] = {}
          }

          if (!approvedSeenTags[primaryAttributeKey][value]) {
            instanceSeenTags[primaryAttributeKey][value] = true
            return true
          }

          return false
        })
        .reverse()
        .forEach(tag => approvedTags.push(tag))

      // Update seen tags with tags from this instance
      const keys = Object.keys(instanceSeenTags)
      for (let i = 0; i < keys.length; i++) {
        const attributeKey = keys[i]
        const tagUnion = Object.assign(
          {},
          approvedSeenTags[attributeKey],
          instanceSeenTags[attributeKey]
        )

        approvedSeenTags[attributeKey] = tagUnion
      }

      return approvedTags
    }, [])
    .reverse()
}

const getInnermostProperty = (propsList, property) => {
  for (let i = propsList.length - 1; i >= 0; i--) {
    const props = propsList[i]

    if (props.hasOwnProperty(property)) {
      return props[property]
    }
  }

  return null
}

const reducePropsToState = propsList => ({
  baseTag: getBaseTagFromPropsList(
    [TAG_PROPERTIES.HREF, TAG_PROPERTIES.TARGET],
    propsList
  ),
  bodyAttributes: getAttributesFromPropsList(ATTRIBUTE_NAMES.BODY, propsList),
  defer: getInnermostProperty(propsList, HELMET_PROPS.DEFER),
  encode: getInnermostProperty(
    propsList,
    HELMET_PROPS.ENCODE_SPECIAL_CHARACTERS
  ),
  htmlAttributes: getAttributesFromPropsList(ATTRIBUTE_NAMES.HTML, propsList),
  linkTags: getTagsFromPropsList(
    TAG_NAMES.LINK,
    [TAG_PROPERTIES.REL, TAG_PROPERTIES.HREF],
    propsList
  ),
  metaTags: getTagsFromPropsList(
    TAG_NAMES.META,
    [
      TAG_PROPERTIES.NAME,
      TAG_PROPERTIES.CHARSET,
      TAG_PROPERTIES.HTTPEQUIV,
      TAG_PROPERTIES.PROPERTY,
      TAG_PROPERTIES.ITEM_PROP
    ],
    propsList
  ),
  noscriptTags: getTagsFromPropsList(
    TAG_NAMES.NOSCRIPT,
    [TAG_PROPERTIES.INNER_HTML],
    propsList
  ),
  onChangeClientState: getOnChangeClientState(propsList),
  scriptTags: getTagsFromPropsList(
    TAG_NAMES.SCRIPT,
    [TAG_PROPERTIES.SRC, TAG_PROPERTIES.INNER_HTML],
    propsList
  ),
  styleTags: getTagsFromPropsList(
    TAG_NAMES.STYLE,
    [TAG_PROPERTIES.CSS_TEXT],
    propsList
  ),
  title: getTitleFromPropsList(propsList),
  titleAttributes: getAttributesFromPropsList(
    ATTRIBUTE_NAMES.TITLE,
    propsList
  )
})

const rafPolyfill = (() => {
  let clock = Date.now()

  return (callback) => {
    const currentTime = Date.now()

    if (currentTime - clock > 16) {
      clock = currentTime
      callback(currentTime)
    } else {
      setTimeout(() => {
        rafPolyfill(callback)
      }, 0)
    }
  }
})()

const cafPolyfill = (id) => clearTimeout(id)

const requestAnimationFrame =
typeof window !== 'undefined'
  ? (window.requestAnimationFrame &&
          window.requestAnimationFrame.bind(window)) ||
      window.webkitRequestAnimationFrame ||
      window.mozRequestAnimationFrame ||
      rafPolyfill
  : global.requestAnimationFrame || rafPolyfill

const cancelAnimationFrame =
typeof window !== 'undefined'
  ? window.cancelAnimationFrame ||
      window.webkitCancelAnimationFrame ||
      window.mozCancelAnimationFrame ||
      cafPolyfill
  : global.cancelAnimationFrame || cafPolyfill

const warn = msg => {
  return console && typeof console.warn === 'function' && console.warn(msg)
}

let _helmetCallback = null

const handleClientStateChange = newState => {
  if (_helmetCallback) {
    cancelAnimationFrame(_helmetCallback)
  }

  if (newState.defer) {
    _helmetCallback = requestAnimationFrame(() => {
      commitTagChanges(newState, () => {
        _helmetCallback = null
      })
    })
  } else {
    commitTagChanges(newState)
    _helmetCallback = null
  }
}

const commitTagChanges = (newState, cb) => {
  const {
    baseTag,
    bodyAttributes,
    htmlAttributes,
    linkTags,
    metaTags,
    noscriptTags,
    onChangeClientState,
    scriptTags,
    styleTags,
    title,
    titleAttributes
  } = newState
  updateAttributes(TAG_NAMES.BODY, bodyAttributes)
  updateAttributes(TAG_NAMES.HTML, htmlAttributes)

  updateTitle(title, titleAttributes)

  const tagUpdates = {
    baseTag: updateTags(TAG_NAMES.BASE, baseTag),
    linkTags: updateTags(TAG_NAMES.LINK, linkTags),
    metaTags: updateTags(TAG_NAMES.META, metaTags),
    noscriptTags: updateTags(TAG_NAMES.NOSCRIPT, noscriptTags),
    scriptTags: updateTags(TAG_NAMES.SCRIPT, scriptTags),
    styleTags: updateTags(TAG_NAMES.STYLE, styleTags)
  }

  const addedTags = {}
  const removedTags = {}

  Object.keys(tagUpdates).forEach(tagType => {
    const { newTags, oldTags } = tagUpdates[tagType]

    if (newTags.length) {
      addedTags[tagType] = newTags
    }
    if (oldTags.length) {
      removedTags[tagType] = tagUpdates[tagType].oldTags
    }
  })

  cb && cb()

  onChangeClientState(newState, addedTags, removedTags)
}

const flattenArray = possibleArray => {
  return Array.isArray(possibleArray)
    ? possibleArray.join('')
    : possibleArray
}

const updateTitle = (title, attributes) => {
  if (typeof title !== 'undefined' && document.title !== title) {
    document.title = flattenArray(title)
  }

  updateAttributes(TAG_NAMES.TITLE, attributes)
}

const updateAttributes = (tagName, attributes) => {
  const elementTag = document.getElementsByTagName(tagName)[0]

  if (!elementTag) {
    return
  }

  const helmetAttributeString = elementTag.getAttribute(HELMET_ATTRIBUTE)
  const helmetAttributes = helmetAttributeString
    ? helmetAttributeString.split(',')
    : []
  const attributesToRemove = [].concat(helmetAttributes)
  const attributeKeys = Object.keys(attributes)

  for (let i = 0; i < attributeKeys.length; i++) {
    const attribute = attributeKeys[i]
    const value = attributes[attribute] || ''

    if (elementTag.getAttribute(attribute) !== value) {
      elementTag.setAttribute(attribute, value)
    }

    if (helmetAttributes.indexOf(attribute) === -1) {
      helmetAttributes.push(attribute)
    }

    const indexToSave = attributesToRemove.indexOf(attribute)
    if (indexToSave !== -1) {
      attributesToRemove.splice(indexToSave, 1)
    }
  }

  for (let i = attributesToRemove.length - 1; i >= 0; i--) {
    elementTag.removeAttribute(attributesToRemove[i])
  }

  if (helmetAttributes.length === attributesToRemove.length) {
    elementTag.removeAttribute(HELMET_ATTRIBUTE)
  } else if (
    elementTag.getAttribute(HELMET_ATTRIBUTE) !== attributeKeys.join(',')
  ) {
    elementTag.setAttribute(HELMET_ATTRIBUTE, attributeKeys.join(','))
  }
}

const updateTags = (type, tags) => {
  const headElement = document.head || document.querySelector(TAG_NAMES.HEAD)
  const tagNodes = headElement.querySelectorAll(
    `${type}[${HELMET_ATTRIBUTE}]`
  )
  const oldTags = Array.prototype.slice.call(tagNodes)
  const newTags = []
  let indexToDelete

  if (tags && tags.length) {
    tags.forEach(tag => {
      const newElement = document.createElement(type)

      for (const attribute in tag) {
        if (tag.hasOwnProperty(attribute)) {
          if (attribute === TAG_PROPERTIES.INNER_HTML) {
            newElement.innerHTML = tag.innerHTML
          } else if (attribute === TAG_PROPERTIES.CSS_TEXT) {
            if (newElement.styleSheet) {
              newElement.styleSheet.cssText = tag.cssText
            } else {
              newElement.appendChild(
                document.createTextNode(tag.cssText)
              )
            }
          } else {
            const value =
                        typeof tag[attribute] === 'undefined'
                          ? ''
                          : tag[attribute]
            newElement.setAttribute(attribute, value)
          }
        }
      }

      newElement.setAttribute(HELMET_ATTRIBUTE, 'true')

      // Remove a duplicate tag from domTagstoRemove, so it isn't cleared.
      if (
        oldTags.some((existingTag, index) => {
          indexToDelete = index
          return newElement.isEqualNode(existingTag)
        })
      ) {
        oldTags.splice(indexToDelete, 1)
      } else {
        newTags.push(newElement)
      }
    })
  }

  oldTags.forEach(tag => tag.parentNode.removeChild(tag))
  newTags.forEach(tag => headElement.appendChild(tag))

  return {
    oldTags,
    newTags
  }
}

const generateElementAttributesAsString = attributes =>
  Object.keys(attributes).reduce((str, key) => {
    const attr =
        typeof attributes[key] !== 'undefined'
          ? `${key}="${attributes[key]}"`
          : `${key}`
    return str ? `${str} ${attr}` : attr
  }, '')

const generateTitleAsString = (type, title, attributes, encode) => {
  const attributeString = generateElementAttributesAsString(attributes)
  const flattenedTitle = flattenArray(title)
  return attributeString
    ? `<${type} ${HELMET_ATTRIBUTE}="true" ${attributeString}>${encodeSpecialCharacters(
      flattenedTitle,
      encode
    )}</${type}>`
    : `<${type} ${HELMET_ATTRIBUTE}="true">${encodeSpecialCharacters(
      flattenedTitle,
      encode
    )}</${type}>`
}

const generateTagsAsString = (type, tags, encode) =>
  tags.reduce((str, tag) => {
    const attributeHtml = Object.keys(tag)
      .filter(
        attribute =>
          !(
            attribute === TAG_PROPERTIES.INNER_HTML ||
                    attribute === TAG_PROPERTIES.CSS_TEXT
          )
      )
      .reduce((string, attribute) => {
        const attr =
                typeof tag[attribute] === 'undefined'
                  ? attribute
                  : `${attribute}="${encodeSpecialCharacters(
                    tag[attribute],
                    encode
                  )}"`
        return string ? `${string} ${attr}` : attr
      }, '')

    const tagContent = tag.innerHTML || tag.cssText || ''

    const isSelfClosing = SELF_CLOSING_TAGS.indexOf(type) === -1

    return `${str}<${type} ${HELMET_ATTRIBUTE}="true" ${attributeHtml}${
      isSelfClosing ? '/>' : `>${tagContent}</${type}>`
    }`
  }, '')

const convertElementAttributestoReactProps = (attributes, initProps = {}) => {
  return Object.keys(attributes).reduce((obj, key) => {
    obj[REACT_TAG_MAP[key] || key] = attributes[key]
    return obj
  }, initProps)
}

const convertReactPropstoHtmlAttributes = (props, initAttributes = {}) => {
  return Object.keys(props).reduce((obj, key) => {
    obj[HTML_TAG_MAP[key] || key] = props[key]
    return obj
  }, initAttributes)
}

const generateTitleAsReactComponent = (type, title, attributes) => {
// assigning into an array to define toString function on it
  const initProps = {
    key: title,
    [HELMET_ATTRIBUTE]: true
  }
  const props = convertElementAttributestoReactProps(attributes, initProps)

  return [React.createElement(TAG_NAMES.TITLE, props, title)]
}

const generateTagsAsReactComponent = (type, tags) =>
  tags.map((tag, i) => {
    const mappedTag = {
      key: i,
      [HELMET_ATTRIBUTE]: true
    }

    Object.keys(tag).forEach(attribute => {
      const mappedAttribute = REACT_TAG_MAP[attribute] || attribute

      if (
        mappedAttribute === TAG_PROPERTIES.INNER_HTML ||
            mappedAttribute === TAG_PROPERTIES.CSS_TEXT
      ) {
        const content = tag.innerHTML || tag.cssText
        mappedTag.dangerouslySetInnerHTML = { __html: content }
      } else {
        mappedTag[mappedAttribute] = tag[attribute]
      }
    })

    return React.createElement(type, mappedTag)
  })

const getMethodsForTag = (type, tags, encode) => {
  switch (type) {
    case TAG_NAMES.TITLE:
      return {
        toComponent: () =>
          generateTitleAsReactComponent(
            type,
            tags.title,
            tags.titleAttributes,
            encode
          ),
        toString: () =>
          generateTitleAsString(
            type,
            tags.title,
            tags.titleAttributes,
            encode
          )
      }
    case ATTRIBUTE_NAMES.BODY:
    case ATTRIBUTE_NAMES.HTML:
      return {
        toComponent: () => convertElementAttributestoReactProps(tags),
        toString: () => generateElementAttributesAsString(tags)
      }
    default:
      return {
        toComponent: () => generateTagsAsReactComponent(type, tags),
        toString: () => generateTagsAsString(type, tags, encode)
      }
  }
}

const mapStateOnServer = ({
  baseTag,
  bodyAttributes,
  encode,
  htmlAttributes,
  linkTags,
  metaTags,
  noscriptTags,
  scriptTags,
  styleTags,
  title = '',
  titleAttributes
}) => ({
  base: getMethodsForTag(TAG_NAMES.BASE, baseTag, encode),
  bodyAttributes: getMethodsForTag(
    ATTRIBUTE_NAMES.BODY,
    bodyAttributes,
    encode
  ),
  htmlAttributes: getMethodsForTag(
    ATTRIBUTE_NAMES.HTML,
    htmlAttributes,
    encode
  ),
  link: getMethodsForTag(TAG_NAMES.LINK, linkTags, encode),
  meta: getMethodsForTag(TAG_NAMES.META, metaTags, encode),
  noscript: getMethodsForTag(TAG_NAMES.NOSCRIPT, noscriptTags, encode),
  script: getMethodsForTag(TAG_NAMES.SCRIPT, scriptTags, encode),
  style: getMethodsForTag(TAG_NAMES.STYLE, styleTags, encode),
  title: getMethodsForTag(TAG_NAMES.TITLE, { title, titleAttributes }, encode)
})

export { convertReactPropstoHtmlAttributes }
export { handleClientStateChange }
export { mapStateOnServer }
export { reducePropsToState }
export { requestAnimationFrame }
export { warn }
