path = require 'path'

_ = require 'underscore'
EmitterMixin = require('emissary').Emitter
{Emitter, Disposable, CompositeDisposable} = require 'event-kit'
{File} = require 'pathwatcher'
fs = require 'fs-plus'
Q = require 'q'
Grim = require 'grim'

Package = require './package'

# Extended: Handles loading and activating available themes.
#
# An instance of this class is always available as the `atom.themes` global.
#
# Section: Atom
module.exports =
class ThemeManager
  EmitterMixin.includeInto(this)

  constructor: ({@packageManager, @resourcePath, @configDirPath, @safeMode}) ->
    @emitter = new Emitter
    @styleSheetDisposablesBySourcePath = {}
    @lessCache = null
    @initialLoadComplete = false
    @packageManager.registerPackageActivator(this, ['theme'])
    @sheetsByStyleElement = new WeakMap

    stylesElement = document.head.querySelector('atom-styles')
    stylesElement.onDidAddStyleElement @styleElementAdded.bind(this)
    stylesElement.onDidRemoveStyleElement @styleElementRemoved.bind(this)
    stylesElement.onDidUpdateStyleElement @styleElementUpdated.bind(this)

  watchCoreStyles: ->
    console.log('Watching /static and /internal_packages for LESS changes')
    watchStylesIn = (folder) =>
      stylePaths = fs.listTreeSync(folder)
      PathWatcher = require 'pathwatcher'
      for stylePath in stylePaths
        continue unless path.extname(stylePath) is '.less'
        PathWatcher.watch stylePath, =>
          @activateThemes()
    watchStylesIn("#{@resourcePath}/static")
    watchStylesIn("#{@resourcePath}/internal_packages")

  styleElementAdded: (styleElement) ->
    {sheet} = styleElement
    @sheetsByStyleElement.set(styleElement, sheet)
    @emit 'stylesheet-added', sheet
    @emitter.emit 'did-add-stylesheet', sheet
    @emit 'stylesheets-changed'
    @emitter.emit 'did-change-stylesheets'

  styleElementRemoved: (styleElement) ->
    sheet = @sheetsByStyleElement.get(styleElement)
    @emit 'stylesheet-removed', sheet
    @emitter.emit 'did-remove-stylesheet', sheet
    @emit 'stylesheets-changed'
    @emitter.emit 'did-change-stylesheets'

  styleElementUpdated: ({sheet}) ->
    @emit 'stylesheet-removed', sheet
    @emitter.emit 'did-remove-stylesheet', sheet
    @emit 'stylesheet-added', sheet
    @emitter.emit 'did-add-stylesheet', sheet
    @emit 'stylesheets-changed'
    @emitter.emit 'did-change-stylesheets'

  ###
  Section: Event Subscription
  ###

  # Essential: Invoke `callback` when style sheet changes associated with
  # updating the list of active themes have completed.
  #
  # * `callback` {Function}
  onDidChangeActiveThemes: (callback) ->
    @emitter.on 'did-change-active-themes', callback
    @emitter.on 'did-reload-all', callback # TODO: Remove once deprecated pre-1.0 APIs are gone

  onDidReloadAll: (callback) ->
    Grim.deprecate("Use `::onDidChangeActiveThemes` instead.")
    @onDidChangeActiveThemes(callback)

  # Deprecated: Invoke `callback` when a stylesheet has been added to the dom.
  #
  # * `callback` {Function}
  #   * `stylesheet` {StyleSheet} the style node
  #
  # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidAddStylesheet: (callback) ->
    Grim.deprecate("Use atom.styles.onDidAddStyleElement instead")
    @emitter.on 'did-add-stylesheet', callback

  # Deprecated: Invoke `callback` when a stylesheet has been removed from the dom.
  #
  # * `callback` {Function}
  #   * `stylesheet` {StyleSheet} the style node
  #
  # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidRemoveStylesheet: (callback) ->
    Grim.deprecate("Use atom.styles.onDidRemoveStyleElement instead")
    @emitter.on 'did-remove-stylesheet', callback

  # Deprecated: Invoke `callback` when a stylesheet has been updated.
  #
  # * `callback` {Function}
  #   * `stylesheet` {StyleSheet} the style node
  #
  # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidUpdateStylesheet: (callback) ->
    Grim.deprecate("Use atom.styles.onDidUpdateStyleElement instead")
    @emitter.on 'did-update-stylesheet', callback

  # Deprecated: Invoke `callback` when any stylesheet has been updated, added, or removed.
  #
  # * `callback` {Function}
  #
  # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidChangeStylesheets: (callback) ->
    Grim.deprecate("Use atom.styles.onDidAdd/RemoveStyleElement instead")
    @emitter.on 'did-change-stylesheets', callback

  on: (eventName) ->
    switch eventName
      when 'reloaded'
        Grim.deprecate 'Use ThemeManager::onDidChangeActiveThemes instead'
      when 'stylesheet-added'
        Grim.deprecate 'Use ThemeManager::onDidAddStylesheet instead'
      when 'stylesheet-removed'
        Grim.deprecate 'Use ThemeManager::onDidRemoveStylesheet instead'
      when 'stylesheet-updated'
        Grim.deprecate 'Use ThemeManager::onDidUpdateStylesheet instead'
      when 'stylesheets-changed'
        Grim.deprecate 'Use ThemeManager::onDidChangeStylesheets instead'
      else
        Grim.deprecate 'ThemeManager::on is deprecated. Use event subscription methods instead.'
    EmitterMixin::on.apply(this, arguments)

  ###
  Section: Accessing Available Themes
  ###

  getAvailableNames: ->
    # TODO: Maybe should change to list all the available themes out there?
    @getLoadedNames()

  ###
  Section: Accessing Loaded Themes
  ###

  # Public: Get an array of all the loaded theme names.
  getLoadedThemeNames: ->
    theme.name for theme in @getLoadedThemes()

  getLoadedNames: ->
    Grim.deprecate("Use `::getLoadedThemeNames` instead.")
    @getLoadedThemeNames()

  # Public: Get an array of all the loaded themes.
  getLoadedThemes: ->
    pack for pack in @packageManager.getLoadedPackages() when pack.isTheme()

  ###
  Section: Accessing Active Themes
  ###

  # Public: Get an array of all the active theme names.
  getActiveThemeNames: ->
    theme.name for theme in @getActiveThemes()

  getActiveNames: ->
    Grim.deprecate("Use `::getActiveThemeNames` instead.")
    @getActiveThemeNames()

  # Public: Get an array of all the active themes.
  getActiveThemes: ->
    pack for pack in @packageManager.getActivePackages() when pack.isTheme()

  activatePackages: -> @activateThemes()

  ###
  Section: Managing Enabled Themes
  ###

  # Public: Get the enabled theme names from the config.
  #
  # Returns an array of theme names in the order that they should be activated.
  getEnabledThemeNames: ->
    themeNames = atom.config.get('core.themes') ? []
    themeNames = [themeNames] unless _.isArray(themeNames)
    themeNames = themeNames.filter (themeName) ->
      if themeName and typeof themeName is 'string'
        return true if atom.packages.resolvePackagePath(themeName)
        console.warn("Enabled theme '#{themeName}' is not installed.")
      false

    # Use a built-in theme any time the configured themes are not
    # available.
    if themeNames.length is 0
      builtInThemeNames = [
        'ui-light', 'ui-dark'
      ]
      themeNames = _.intersection(themeNames, builtInThemeNames)
      if themeNames.length is 0
        themeNames = ['ui-light']

    # Reverse so the first (top) theme is loaded after the others. We want
    # the first/top theme to override later themes in the stack.
    themeNames.reverse()

  # Set the list of enabled themes.
  #
  # * `enabledThemeNames` An {Array} of {String} theme names.
  setEnabledThemes: (enabledThemeNames) ->
    Grim.deprecate("Use `atom.config.set('core.themes', arrayOfThemeNames)` instead")
    atom.config.set('core.themes', enabledThemeNames)

  ###
  Section: Private
  ###

  # Returns the {String} path to the user's stylesheet under ~/.nylas
  getUserStylesheetPath: ->
    Grim.deprecate("Call atom.styles.getUserStyleSheetPath() instead")
    atom.styles.getUserStyleSheetPath()

  # Resolve and apply the stylesheet specified by the path.
  #
  # This supports both CSS and Less stylsheets.
  #
  # * `stylesheetPath` A {String} path to the stylesheet that can be an absolute
  #   path or a relative path that will be resolved against the load path.
  #
  # Returns a {Disposable} on which `.dispose()` can be called to remove the
  # required stylesheet.
  requireStylesheet: (stylesheetPath) ->
    if fullPath = @resolveStylesheet(stylesheetPath)
      content = @loadStylesheet(fullPath)
      @applyStylesheet(fullPath, content)
    else
      throw new Error("Could not find a file at path '#{stylesheetPath}'")

  unwatchUserStylesheet: ->
    @userStylsheetSubscriptions?.dispose()
    @userStylsheetSubscriptions = null
    @userStylesheetFile = null
    @userStyleSheetDisposable?.dispose()
    @userStyleSheetDisposable = null

  loadUserStylesheet: ->
    @unwatchUserStylesheet()

    userStylesheetPath = atom.styles.getUserStyleSheetPath()
    return unless fs.isFileSync(userStylesheetPath)
    try
      @userStylesheetFile = new File(userStylesheetPath)
      @userStylsheetSubscriptions = new CompositeDisposable()
      reloadStylesheet = => @loadUserStylesheet()
      @userStylsheetSubscriptions.add(@userStylesheetFile.onDidChange(reloadStylesheet))
      @userStylsheetSubscriptions.add(@userStylesheetFile.onDidRename(reloadStylesheet))
      @userStylsheetSubscriptions.add(@userStylesheetFile.onDidDelete(reloadStylesheet))
    catch error
      message = """
        Unable to watch path: `#{path.basename(userStylesheetPath)}`. Make sure
        you have permissions to `#{userStylesheetPath}`.

        On linux there are currently problems with watch sizes. See
        [this document][watches] for more info.
        [watches]:https://github.com/atom/atom/blob/master/docs/build-instructions/linux.md#typeerror-unable-to-watch-path
      """
      console.error(message, dismissable: true)
      console.error(error.toString())

    try
      userStylesheetContents = @loadStylesheet(userStylesheetPath, true)
    catch
      return

    @userStyleSheetDisposable = atom.styles.addStyleSheet(userStylesheetContents, sourcePath: userStylesheetPath, priority: 2)

  loadBaseStylesheets: ->
    @reloadBaseStylesheets()

  reloadBaseStylesheets: ->
    @requireStylesheet('../static/index')
    @requireStylesheet('../static/email-frame')
    if nativeStylesheetPath = fs.resolveOnLoadPath(process.platform, ['css', 'less'])
      @requireStylesheet(nativeStylesheetPath)

  stylesheetElementForId: (id) ->
    document.head.querySelector("atom-styles style[source-path=\"#{id}\"]")

  resolveStylesheet: (stylesheetPath) ->
    if path.extname(stylesheetPath).length > 0
      fs.resolveOnLoadPath(stylesheetPath)
    else
      fs.resolveOnLoadPath(stylesheetPath, ['css', 'less'])

  loadStylesheet: (stylesheetPath, importFallbackVariables) ->
    if path.extname(stylesheetPath) is '.less'
      @loadLessStylesheet(stylesheetPath, importFallbackVariables)
    else
      fs.readFileSync(stylesheetPath, 'utf8')

  loadLessStylesheet: (lessStylesheetPath, importFallbackVariables=false) ->
    unless @lessCache?
      LessCompileCache = require './less-compile-cache'
      @lessCache = new LessCompileCache({@resourcePath, importPaths: @getImportPaths()})

    try
      if importFallbackVariables
        baseVarImports = """
        @import "variables/ui-variables";
        """
        less = fs.readFileSync(lessStylesheetPath, 'utf8')
        @lessCache.cssForFile(lessStylesheetPath, [baseVarImports, less].join('\n'))
      else
        @lessCache.read(lessStylesheetPath)
    catch error
      if error.line?
        message = "Error compiling Less stylesheet: `#{lessStylesheetPath}`"
        detail = """
          Line number: #{error.line}
          #{error.message}
        """
      else
        message = "Error loading Less stylesheet: `#{lessStylesheetPath}`"
        detail = error.message

      console.error(message, {detail, dismissable: true})
      console.error(detail)
      throw error

  removeStylesheet: (stylesheetPath) ->
    @styleSheetDisposablesBySourcePath[stylesheetPath]?.dispose()

  applyStylesheet: (path, text) ->
    @styleSheetDisposablesBySourcePath[path] = atom.styles.addStyleSheet(text, sourcePath: path)

  stringToId: (string) ->
    string.replace(/\\/g, '/')

  activateThemes: ->
    deferred = Q.defer()

    # atom.config.observe runs the callback once, then on subsequent changes.
    atom.config.observe 'core.themes', =>
      @deactivateThemes()

      @refreshLessCache() # Update cache for packages in core.themes config

      promises = []
      for themeName in @getEnabledThemeNames()
        if @packageManager.resolvePackagePath(themeName)
          promises.push(@packageManager.activatePackage(themeName))
        else
          console.warn("Failed to activate theme '#{themeName}' because it isn't installed.")

      Q.all(promises).then =>
        @addActiveThemeClasses()
        @refreshLessCache() # Update cache again now that @getActiveThemes() is populated
        @loadUserStylesheet()
        @reloadBaseStylesheets()
        @initialLoadComplete = true
        @emit 'reloaded'
        @emitter.emit 'did-change-active-themes'
        deferred.resolve()

    deferred.promise

  deactivateThemes: ->
    @removeActiveThemeClasses()
    @unwatchUserStylesheet()
    @packageManager.deactivatePackage(pack.name) for pack in @getActiveThemes()
    null

  isInitialLoadComplete: -> @initialLoadComplete

  addActiveThemeClasses: ->
    workspaceElement = document.getElementsByTagName('atom-workspace')[0]
    return unless workspaceElement
    for pack in @getActiveThemes()
      workspaceElement.classList.add("theme-#{pack.name}")
    return

  removeActiveThemeClasses: ->
    workspaceElement = document.getElementsByTagName('atom-workspace')[0]
    return unless workspaceElement
    for pack in @getActiveThemes()
      workspaceElement.classList.remove("theme-#{pack.name}")
    return

  refreshLessCache: ->
    @lessCache?.setImportPaths(@getImportPaths())

  getImportPaths: ->
    activeThemes = @getActiveThemes()
    if activeThemes.length > 0
      themePaths = (theme.getStylesheetsPath() for theme in activeThemes when theme)
    else
      themePaths = []
      for themeName in @getEnabledThemeNames()
        if themePath = @packageManager.resolvePackagePath(themeName)
          deprecatedPath = path.join(themePath, 'stylesheets')
          if fs.isDirectorySync(deprecatedPath)
            themePaths.push(deprecatedPath)
          else
            themePaths.push(path.join(themePath, 'styles'))

    themePaths.filter (themePath) -> fs.isDirectorySync(themePath)
