import warning from 'tiny-warning'
import StyleSheet from './StyleSheet'
import sheets from './sheets'
import toCssValue from './utils/toCssValue'

/**
 * Cache the value from the first time a function is called.
 */
const memoize = (fn) => {
  let value
  return () => {
    if (!value) value = fn()
    return value
  }
}

/**
 * Get a style property value.
 */
const getPropertyValue = (cssRule, prop) => {
  try {
    // Support CSSTOM.
    if (cssRule.attributeStyleMap) {
      return cssRule.attributeStyleMap.get(prop)
    }
    return cssRule.style.getPropertyValue(prop)
  } catch (err) {
    // IE may throw if property is unknown.
    return ''
  }
}

/**
 * Set a style property.
 */
const setProperty = (cssRule, prop, value) => {
  try {
    let cssValue = value

    if (Array.isArray(value)) {
      cssValue = toCssValue(value)
    }

    // Support CSSTOM.
    if (cssRule.attributeStyleMap) {
      cssRule.attributeStyleMap.set(prop, cssValue)
    } else {
      const indexOfImportantFlag = cssValue ? cssValue.indexOf('!important') : -1

      const cssValueWithoutImportantFlag =
        indexOfImportantFlag > -1 ? cssValue.substr(0, indexOfImportantFlag - 1) : cssValue

      cssRule.style.setProperty(
        prop,
        cssValueWithoutImportantFlag,
        indexOfImportantFlag > -1 ? 'important' : ''
      )
    }
  } catch (err) {
    // IE may throw if property is unknown.
    return false
  }
  return true
}

/**
 * Remove a style property.
 */
const removeProperty = (cssRule, prop) => {
  try {
    // Support CSSTOM.
    if (cssRule.attributeStyleMap) {
      cssRule.attributeStyleMap.delete(prop)
    } else {
      cssRule.style.removeProperty(prop)
    }
  } catch (err) {
    warning(
      false,
      `[JSS] DOMException "${err.message}" was thrown. Tried to remove property "${prop}".`
    )
  }
}

/**
 * Set the selector.
 */
const setSelector = (cssRule, selectorText) => {
  cssRule.selectorText = selectorText

  // Return false if setter was not successful.
  // Currently works in chrome only.
  return cssRule.selectorText === selectorText
}

/**
 * Gets the `head` element upon the first call and caches it.
 * We assume it can't be null.
 */
const getHead = memoize(() => document.querySelector('head'))

/**
 * Find attached sheet with an index higher than the passed one.
 */
function findHigherSheet(registry, options) {
  for (let i = 0; i < registry.length; i++) {
    const sheet = registry[i]
    if (
      sheet.attached &&
      sheet.options.index > options.index &&
      sheet.options.insertionPoint === options.insertionPoint
    ) {
      return sheet
    }
  }
  return null
}

/**
 * Find attached sheet with the highest index.
 */
function findHighestSheet(registry, options) {
  for (let i = registry.length - 1; i >= 0; i--) {
    const sheet = registry[i]
    if (sheet.attached && sheet.options.insertionPoint === options.insertionPoint) {
      return sheet
    }
  }
  return null
}

/**
 * Find a comment with "jss" inside.
 */
function findCommentNode(text) {
  const head = getHead()
  for (let i = 0; i < head.childNodes.length; i++) {
    const node = head.childNodes[i]
    if (node.nodeType === 8 && node.nodeValue.trim() === text) {
      return node
    }
  }
  return null
}

/**
 * Find a node before which we can insert the sheet.
 */
function findPrevNode(options) {
  const {registry} = sheets

  if (registry.length > 0) {
    // Try to insert before the next higher sheet.
    let sheet = findHigherSheet(registry, options)
    if (sheet && sheet.renderer) {
      return {
        parent: sheet.renderer.element.parentNode,
        node: sheet.renderer.element
      }
    }

    // Otherwise insert after the last attached.
    sheet = findHighestSheet(registry, options)
    if (sheet && sheet.renderer) {
      return {
        parent: sheet.renderer.element.parentNode,
        node: sheet.renderer.element.nextSibling
      }
    }
  }

  // Try to find a comment placeholder if registry is empty.
  const {insertionPoint} = options
  if (insertionPoint && typeof insertionPoint === 'string') {
    const comment = findCommentNode(insertionPoint)
    if (comment) {
      return {
        parent: comment.parentNode,
        node: comment.nextSibling
      }
    }

    // If user specifies an insertion point and it can't be found in the document -
    // bad specificity issues may appear.
    warning(false, `[JSS] Insertion point "${insertionPoint}" not found.`)
  }

  return false
}

/**
 * Insert style element into the DOM.
 */
function insertStyle(style, options) {
  const {insertionPoint} = options
  const nextNode = findPrevNode(options)

  if (nextNode !== false && nextNode.parent) {
    nextNode.parent.insertBefore(style, nextNode.node)

    return
  }

  // Works with iframes and any node types.
  if (insertionPoint && typeof insertionPoint.nodeType === 'number') {
    const insertionPointElement = insertionPoint
    const {parentNode} = insertionPointElement
    if (parentNode) parentNode.insertBefore(style, insertionPointElement.nextSibling)
    else warning(false, '[JSS] Insertion point is not in the DOM.')
    return
  }

  getHead().appendChild(style)
}

/**
 * Read jss nonce setting from the page if the user has set it.
 */
const getNonce = memoize(() => {
  const node = document.querySelector('meta[property="csp-nonce"]')
  return node ? node.getAttribute('content') : null
})

const insertRule = (container, rule, index) => {
  try {
    if ('insertRule' in container) {
      container.insertRule(rule, index)
    }
    // Keyframes rule.
    else if ('appendRule' in container) {
      container.appendRule(rule)
    }
  } catch (err) {
    warning(false, `[JSS] ${err.message}`)
    return false
  }
  return container.cssRules[index]
}

const getValidRuleInsertionIndex = (container, index) => {
  const maxIndex = container.cssRules.length
  // In case previous insertion fails, passed index might be wrong
  if (index === undefined || index > maxIndex) {
    // eslint-disable-next-line no-param-reassign
    return maxIndex
  }
  return index
}

const createStyle = () => {
  const el = document.createElement('style')
  // Without it, IE will have a broken source order specificity if we
  // insert rules after we insert the style tag.
  // It seems to kick-off the source order specificity algorithm.
  el.textContent = '\n'
  return el
}

export default class DomRenderer {
  getPropertyValue = getPropertyValue

  setProperty = setProperty

  removeProperty = removeProperty

  setSelector = setSelector

  hasInsertedRules = false

  // Will be empty if link: true option is not set, because
  // it is only for use together with insertRule API.
  cssRules = []

  constructor(sheet) {
    // There is no sheet when the renderer is used from a standalone StyleRule.
    if (sheet) sheets.add(sheet)

    this.sheet = sheet
    const {media, meta, element} = this.sheet ? this.sheet.options : {}
    this.element = element || createStyle()
    this.element.setAttribute('data-jss', '')
    if (media) this.element.setAttribute('media', media)
    if (meta) this.element.setAttribute('data-meta', meta)
    const nonce = getNonce()
    if (nonce) this.element.setAttribute('nonce', nonce)
  }

  /**
   * Insert style element into render tree.
   */
  attach() {
    // In the case the element node is external and it is already in the DOM.
    if (this.element.parentNode || !this.sheet) return

    insertStyle(this.element, this.sheet.options)

    // When rules are inserted using `insertRule` API, after `sheet.detach().attach()`
    // most browsers create a new CSSStyleSheet, except of all IEs.
    const deployed = Boolean(this.sheet && this.sheet.deployed)
    if (this.hasInsertedRules && deployed) {
      this.hasInsertedRules = false
      this.deploy()
    }
  }

  /**
   * Remove style element from render tree.
   */
  detach() {
    if (!this.sheet) return
    const {parentNode} = this.element
    if (parentNode) parentNode.removeChild(this.element)
    // In the most browsers, rules inserted using insertRule() API will be lost when style element is removed.
    // Though IE will keep them and we need a consistent behavior.
    if (this.sheet.options.link) {
      this.cssRules = []
      this.element.textContent = '\n'
    }
  }

  /**
   * Inject CSS string into element.
   */
  deploy() {
    const {sheet} = this
    if (!sheet) return
    if (sheet.options.link) {
      this.insertRules(sheet.rules)
      return
    }
    this.element.textContent = `\n${sheet.toString()}\n`
  }

  /**
   * Insert RuleList into an element.
   */

  insertRules(rules, nativeParent) {
    for (let i = 0; i < rules.index.length; i++) {
      this.insertRule(rules.index[i], i, nativeParent)
    }
  }

  /**
   * Insert a rule into element.
   */
  insertRule(rule, index, nativeParent = this.element.sheet) {
    if (rule.rules) {
      const parent = rule
      let latestNativeParent = nativeParent
      if (rule.type === 'conditional' || rule.type === 'keyframes') {
        const insertionIndex = getValidRuleInsertionIndex(nativeParent, index)
        // We need to render the container without children first.
        latestNativeParent = insertRule(
          nativeParent,
          parent.toString({children: false}),
          insertionIndex
        )
        if (latestNativeParent === false) {
          return false
        }
        this.refCssRule(rule, insertionIndex, latestNativeParent)
      }
      this.insertRules(parent.rules, latestNativeParent)
      return latestNativeParent
    }

    const ruleStr = rule.toString()

    if (!ruleStr) return false

    const insertionIndex = getValidRuleInsertionIndex(nativeParent, index)
    const nativeRule = insertRule(nativeParent, ruleStr, insertionIndex)
    if (nativeRule === false) {
      return false
    }

    this.hasInsertedRules = true
    this.refCssRule(rule, insertionIndex, nativeRule)

    return nativeRule
  }

  refCssRule(rule, index, cssRule) {
    rule.renderable = cssRule
    // We only want to reference the top level rules, deleteRule API doesn't support removing nested rules
    // like rules inside media queries or keyframes
    if (rule.options.parent instanceof StyleSheet) {
      this.cssRules.splice(index, 0, cssRule)
    }
  }

  /**
   * Delete a rule.
   */
  deleteRule(cssRule) {
    const {sheet} = this.element
    const index = this.indexOf(cssRule)
    if (index === -1) return false
    sheet.deleteRule(index)
    this.cssRules.splice(index, 1)
    return true
  }

  /**
   * Get index of a CSS Rule.
   */
  indexOf(cssRule) {
    return this.cssRules.indexOf(cssRule)
  }

  /**
   * Generate a new CSS rule and replace the existing one.
   */
  replaceRule(cssRule, rule) {
    const index = this.indexOf(cssRule)
    if (index === -1) return false
    this.element.sheet.deleteRule(index)
    this.cssRules.splice(index, 1)
    return this.insertRule(rule, index)
  }

  /**
   * Get all rules elements.
   */
  getRules() {
    return this.element.sheet.cssRules
  }
}
