const {Emitter, Disposable} = require('event-kit')
const crypto = require('crypto')
const fs = require('fs-plus')
const path = require('path')
const postcss = require('postcss')
const selectorParser = require('postcss-selector-parser')
const StylesElement = require('./styles-element')
const DEPRECATED_SYNTAX_SELECTORS = require('./deprecated-syntax-selectors')

// Extended: A singleton instance of this class available via `atom.styles`,
// which you can use to globally query and observe the set of active style
// sheets. The `StyleManager` doesn't add any style elements to the DOM on its
// own, but is instead subscribed to by individual `<atom-styles>` elements,
// which clone and attach style elements in different contexts.
module.exports = class StyleManager {
  constructor () {
    this.emitter = new Emitter()
    this.styleElements = []
    this.styleElementsBySourcePath = {}
    this.deprecationsBySourcePath = {}
  }

  initialize ({configDirPath}) {
    this.configDirPath = configDirPath
    if (this.configDirPath != null) {
      this.cacheDirPath = path.join(this.configDirPath, 'compile-cache', 'style-manager')
    }
  }

  /*
  Section: Event Subscription
  */

  // Extended: Invoke `callback` for all current and future style elements.
  //
  // * `callback` {Function} that is called with style elements.
  //   * `styleElement` An `HTMLStyleElement` instance. The `.sheet` property
  //     will be null because this element isn't attached to the DOM. If you want
  //     to attach this element to the DOM, be sure to clone it first by calling
  //     `.cloneNode(true)` on it. The style element will also have the following
  //     non-standard properties:
  //     * `sourcePath` A {String} containing the path from which the style
  //       element was loaded.
  //     * `context` A {String} indicating the target context of the style
  //       element.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to cancel the
  // subscription.
  observeStyleElements (callback) {
    for (let styleElement of this.getStyleElements()) {
      callback(styleElement)
    }

    return this.onDidAddStyleElement(callback)
  }

  // Extended: Invoke `callback` when a style element is added.
  //
  // * `callback` {Function} that is called with style elements.
  //   * `styleElement` An `HTMLStyleElement` instance. The `.sheet` property
  //     will be null because this element isn't attached to the DOM. If you want
  //     to attach this element to the DOM, be sure to clone it first by calling
  //     `.cloneNode(true)` on it. The style element will also have the following
  //     non-standard properties:
  //     * `sourcePath` A {String} containing the path from which the style
  //       element was loaded.
  //     * `context` A {String} indicating the target context of the style
  //       element.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to cancel the
  // subscription.
  onDidAddStyleElement (callback) {
    return this.emitter.on('did-add-style-element', callback)
  }

  // Extended: Invoke `callback` when a style element is removed.
  //
  // * `callback` {Function} that is called with style elements.
  //   * `styleElement` An `HTMLStyleElement` instance.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to cancel the
  // subscription.
  onDidRemoveStyleElement (callback) {
    return this.emitter.on('did-remove-style-element', callback)
  }

  // Extended: Invoke `callback` when an existing style element is updated.
  //
  // * `callback` {Function} that is called with style elements.
  //   * `styleElement` An `HTMLStyleElement` instance. The `.sheet` property
  //      will be null because this element isn't attached to the DOM. The style
  //      element will also have the following non-standard properties:
  //     * `sourcePath` A {String} containing the path from which the style
  //       element was loaded.
  //     * `context` A {String} indicating the target context of the style
  //       element.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to cancel the
  // subscription.
  onDidUpdateStyleElement (callback) {
    return this.emitter.on('did-update-style-element', callback)
  }

  onDidUpdateDeprecations (callback) {
    return this.emitter.on('did-update-deprecations', callback)
  }

  /*
  Section: Reading Style Elements
  */

  // Extended: Get all loaded style elements.
  getStyleElements () {
    return this.styleElements.slice()
  }

  addStyleSheet (source, params = {}) {
    let styleElement
    let updated
    if (params.sourcePath != null && this.styleElementsBySourcePath[params.sourcePath] != null) {
      updated = true
      styleElement = this.styleElementsBySourcePath[params.sourcePath]
    } else {
      updated = false
      styleElement = document.createElement('style')
      if (params.sourcePath != null) {
        styleElement.sourcePath = params.sourcePath
        styleElement.setAttribute('source-path', params.sourcePath)
      }
      if (params.context != null) {
        styleElement.context = params.context
        styleElement.setAttribute('context', params.context)
      }
      if (params.priority != null) {
        styleElement.priority = params.priority
        styleElement.setAttribute('priority', params.priority)
      }
    }

    if (params.skipDeprecatedSelectorsTransformation) {
      styleElement.textContent = source
    } else {
      const transformed = this.upgradeDeprecatedSelectorsForStyleSheet(source, params.context)
      styleElement.textContent = transformed.source
      if (transformed.deprecationMessage) {
        this.deprecationsBySourcePath[params.sourcePath] = {message: transformed.deprecationMessage}
        this.emitter.emit('did-update-deprecations')
      }
    }

    if (updated) {
      this.emitter.emit('did-update-style-element', styleElement)
    } else {
      this.addStyleElement(styleElement)
    }
    return new Disposable(() => { this.removeStyleElement(styleElement) })
  }

  addStyleElement (styleElement) {
    let insertIndex = this.styleElements.length
    if (styleElement.priority != null) {
      for (let i = 0; i < this.styleElements.length; i++) {
        const existingElement = this.styleElements[i]
        if (existingElement.priority > styleElement.priority) {
          insertIndex = i
          break
        }
      }
    }

    this.styleElements.splice(insertIndex, 0, styleElement)
    if (styleElement.sourcePath != null && this.styleElementsBySourcePath[styleElement.sourcePath] == null) {
      this.styleElementsBySourcePath[styleElement.sourcePath] = styleElement
    }
    this.emitter.emit('did-add-style-element', styleElement)
  }

  removeStyleElement (styleElement) {
    const index = this.styleElements.indexOf(styleElement)
    if (index !== -1) {
      this.styleElements.splice(index, 1)
      if (styleElement.sourcePath != null) {
        delete this.styleElementsBySourcePath[styleElement.sourcePath]
      }
      this.emitter.emit('did-remove-style-element', styleElement)
    }
  }

  upgradeDeprecatedSelectorsForStyleSheet (styleSheet, context) {
    if (this.cacheDirPath != null) {
      const hash = crypto.createHash('sha1')
      if (context != null) {
        hash.update(context)
      }
      hash.update(styleSheet)
      const cacheFilePath = path.join(this.cacheDirPath, hash.digest('hex'))
      try {
        return JSON.parse(fs.readFileSync(cacheFilePath))
      } catch (e) {
        const transformed = transformDeprecatedShadowDOMSelectors(styleSheet, context)
        fs.writeFileSync(cacheFilePath, JSON.stringify(transformed))
        return transformed
      }
    } else {
      return transformDeprecatedShadowDOMSelectors(styleSheet, context)
    }
  }

  getDeprecations () {
    return this.deprecationsBySourcePath
  }

  clearDeprecations () {
    this.deprecationsBySourcePath = {}
  }

  getSnapshot () {
    return this.styleElements.slice()
  }

  restoreSnapshot (styleElementsToRestore) {
    for (let styleElement of this.getStyleElements()) {
      if (!styleElementsToRestore.includes(styleElement)) {
        this.removeStyleElement(styleElement)
      }
    }

    const existingStyleElements = this.getStyleElements()
    for (let styleElement of styleElementsToRestore) {
      if (!existingStyleElements.includes(styleElement)) {
        this.addStyleElement(styleElement)
      }
    }
  }

  buildStylesElement () {
    var stylesElement = new StylesElement()
    stylesElement.initialize(this)
    return stylesElement
  }

  /*
  Section: Paths
  */

  // Extended: Get the path of the user style sheet in `~/.atom`.
  //
  // Returns a {String}.
  getUserStyleSheetPath () {
    if (this.configDirPath == null) {
      return ''
    } else {
      const stylesheetPath = fs.resolve(path.join(this.configDirPath, 'styles'), ['css', 'less'])
      if (fs.isFileSync(stylesheetPath)) {
        return stylesheetPath
      } else {
        return path.join(this.configDirPath, 'styles.less')
      }
    }
  }
}

function transformDeprecatedShadowDOMSelectors (css, context) {
  const transformedSelectors = []
  let transformedSource
  try {
    transformedSource = postcss.parse(css)
  } catch (e) {
    transformedSource = null
  }

  if (transformedSource) {
    transformedSource.walkRules((rule) => {
      const transformedSelector = selectorParser((selectors) => {
        selectors.each((selector) => {
          const firstNode = selector.nodes[0]
          if (context === 'atom-text-editor' && firstNode.type === 'pseudo' && firstNode.value === ':host') {
            const atomTextEditorElementNode = selectorParser.tag({value: 'atom-text-editor'})
            firstNode.replaceWith(atomTextEditorElementNode)
          }

          let previousNodeIsAtomTextEditor = false
          let targetsAtomTextEditorShadow = context === 'atom-text-editor'
          let previousNode
          selector.each((node) => {
            if (targetsAtomTextEditorShadow && node.type === 'class') {
              if (DEPRECATED_SYNTAX_SELECTORS.has(node.value)) {
                node.value = `syntax--${node.value}`
              }
            } else {
              if (previousNodeIsAtomTextEditor && node.type === 'pseudo' && node.value === '::shadow') {
                node.type = 'className'
                node.value = '.editor'
                targetsAtomTextEditorShadow = true
              }
            }

            previousNode = node
            if (node.type === 'combinator') {
              previousNodeIsAtomTextEditor = false
            } else if (previousNode.type === 'tag' && previousNode.value === 'atom-text-editor') {
              previousNodeIsAtomTextEditor = true
            }
          })
        })
      }).process(rule.selector, {lossless: true}).result
      if (transformedSelector !== rule.selector) {
        transformedSelectors.push({before: rule.selector, after: transformedSelector})
        rule.selector = transformedSelector
      }
    })
    let deprecationMessage
    if (transformedSelectors.length > 0) {
      deprecationMessage = 'Starting from Atom v1.13.0, the contents of `atom-text-editor` elements '
      deprecationMessage += 'are no longer encapsulated within a shadow DOM boundary. '
      deprecationMessage += 'This means you should stop using `:host` and `::shadow` '
      deprecationMessage += 'pseudo-selectors, and prepend all your syntax selectors with `syntax--`. '
      deprecationMessage += 'To prevent breakage with existing style sheets, Atom will automatically '
      deprecationMessage += 'upgrade the following selectors:\n\n'
      deprecationMessage += transformedSelectors
        .map((selector) => `* \`${selector.before}\` => \`${selector.after}\``)
        .join('\n\n') + '\n\n'
      deprecationMessage += 'Automatic translation of selectors will be removed in a few release cycles to minimize startup time. '
      deprecationMessage += 'Please, make sure to upgrade the above selectors as soon as possible.'
    }
    return {source: transformedSource.toString(), deprecationMessage}
  } else {
    // CSS was malformed so we don't transform it.
    return {source: css}
  }
}
