###
Framework
=========
###

assert = require 'cassert'
fs = require "fs"
JaySchema = require 'jayschema'
RJSON = require 'relaxed-json'
i18n = require 'i18n-pimatic'
express = require "express"
methodOverride = require 'method-override'
connectTimeout = require 'connect-timeout'
cookieParser = require 'cookie-parser'
bodyParser = require 'body-parser'
cookieSession = require 'cookie-session'
basicAuth = require 'basic-auth'
socketIo = require 'socket.io'
# Require engine.io from socket.io
engineIo = require.cache[require.resolve('socket.io')].require('engine.io')
Promise = require 'bluebird'
path = require 'path'
S = require 'string'
_ = require 'lodash'
declapi = require 'decl-api'
util = require 'util'
jsonlint = require 'yet-another-jsonlint'
events = require 'events'

module.exports = (env) ->

  class Framework extends events.EventEmitter
    configFile: null
    app: null
    io: null
    ruleManager: null
    pluginManager: null
    variableManager: null
    deviceManager: null
    groupManager: null
    pageManager: null
    database: null
    config: null
    _publicPathes: {}

    constructor: (@configFile) ->
      assert @configFile?
      @maindir = path.resolve __dirname, '..'
      env.logger.winston.on("logged", (level, msg, meta) =>
        @_emitMessageLoggedEvent(level, msg, meta)
      )
      @pluginManager = new env.plugins.PluginManager(this)
      @pluginManager.on('updateProcessStatus', (status, info) =>
        @_emitUpdateProcessStatus(status, info)
      )
      @pluginManager.on('updateProcessMessage', (message, info) =>
        @_emitUpdateProcessMessage(message, info)
      )
      @packageJson = @pluginManager.getInstalledPackageInfo('pimatic')
      env.logger.info "Starting pimatic version #{@packageJson.version}"
      env.logger.info "Node.js version #{process.versions.node}"
      env.logger.info "OpenSSL version #{process.versions.openssl}"
      @_loadConfig()
      @pluginManager.pluginsConfig = @config.plugins
      @userManager = new env.users.UserManager(this, @config.users, @config.roles)
      @deviceManager = new env.devices.DeviceManager(this, @config.devices)
      @groupManager = new env.groups.GroupManager(this, @config.groups)
      @pageManager = new env.pages.PageManager(this, @config.pages)
      @variableManager = new env.variables.VariableManager(this, @config.variables)
      @ruleManager = new env.rules.RuleManager(this, @config.rules)
      @database = new env.database.Database(this, @config.settings.database)
      @deviceManager.on('deviceRemoved', (device) =>
        group = @groupManager.getGroupOfDevice(device.id)
        @groupManager.removeDeviceFromGroup(group.id, device.id) if group?
        @pageManager.removeDeviceFromAllPages(device.id)
      )
      @ruleManager.on('ruleRemoved', (rule) =>
        group = @groupManager.getGroupOfRule(rule.id)
        @groupManager.removeRuleFromGroup(group.id, rule.id) if group?
      )
      @variableManager.on('variableRemoved', (variable) =>
        group = @groupManager.getGroupOfVariable(variable.name)
        @groupManager.removeVariableFromGroup(group.id, variable.name) if group?
      )
      for discoverEvent in ['discover', 'discoverMessage', 'deviceDiscovered']
        do (discoverEvent) =>
          @deviceManager.on(discoverEvent, (eventData) => @io?.emit(discoverEvent, eventData) )

      @_setupExpressApp()

    _normalizeScheme: (scheme) ->
      if scheme._normalized then return
      if scheme.type is "object" and typeof scheme.properties is "object"
        requiredProps = scheme.required or []
        for own prop, s of scheme.properties
          isRequired = true
          if typeof s.required is "boolean"
            if s.required is false
              isRequired = false
            delete s.required
          if s.default?
            isRequired = false
          if isRequired and not (prop in requiredProps)
            requiredProps.push prop
          @_normalizeScheme(s) if s?
          if s.defines?.options?
            for own optName, opt of s.defines.options
              @_normalizeScheme(opt)
        if requiredProps.length > 0
          scheme.required = requiredProps
        unless scheme.additionalProperties?
          scheme.additionalProperties = false
      if scheme.type is "array"
        @_normalizeScheme(scheme.items) if scheme.items?
      scheme._normalized = true

    _validateConfig: (config, schema, scope = "config") ->
      js = new JaySchema()
      errors = js.validate(config, schema)
      if errors.length > 0
        errorMessage = "Invalid #{scope}: "
        for e, i in errors
          if i > 0 then errorMessage += ", "
          if e.kind is "ObjectValidationError" and e.constraintName is "required"
            errorMessage += e.desc.replace(/^missing: (.*)$/, 'Missing property "$1"')
          else if e.kind is "ObjectValidationError" and
              e.constraintName is "additionalProperties" and e.testedValue?
            errorMessage += "Property \"#{e.testedValue}\" is not a valid property"
          else if e.desc?
            errorMessage += e.desc
          else
            errorMessage += (
              "Property \"#{e.instanceContext}\" Should have #{e.constraintName} " +
              "#{e.constraintValue}"
            )
            if e.testedValue? then errorMessage += ", was: #{e.testedValue}"
          if e.instanceContext? and e.instanceContext.length > 1
            errorMessage += " in " + e.instanceContext.replace('#', '')
        #throw new Error(errorMessage)
        env.logger.error(errorMessage)

    _loadConfig: () ->
      schema = require("../config-schema")
      contents = fs.readFileSync(@configFile).toString()
      instance = jsonlint.parse(RJSON.transform(contents))

      # some legacy support for old single user
      auth = instance.settings?.authentication
      if auth?.username? and auth?.password? and (not instance.users?)
        unless instance.users?
          instance.users = [
            {
              username: auth.username,
              password: auth.password,
              role: "admin"
            }
          ]
          delete auth.username
          delete auth.password
          env.logger.warn("Move user authentication setting to new users definition!")

      @_normalizeScheme(schema)
      @_validateConfig(instance, schema)
      @config = declapi.enhanceJsonSchemaWithDefaults(schema, instance)
      for role, i in @config.roles
        @config.roles[i] = declapi.enhanceJsonSchemaWithDefaults(
          schema.properties.roles.items,
          role
        )
      assert Array.isArray @config.plugins
      assert Array.isArray @config.devices
      assert Array.isArray @config.pages
      assert Array.isArray @config.groups
      @_checkConfig(@config)

      # * Set the log level
      env.logger.winston.transports.taggedConsoleLogger.level = @config.settings.logLevel

      if @config.settings.debug
        env.logger.logDebug = true
        env.logger.debug("settings.debug is true, showing debug output for pimatic core.")

      i18n.configure({
        locales:['en', 'de'],
        directory: __dirname + '/../locales',
        defaultLocale: @config.settings.locale,
      })

      events.EventEmitter.defaultMaxListeners = @config.settings.defaultMaxListeners


    _checkConfig: (config)->

      checkForDublicate = (type, collection, idProperty) =>
        ids = {}
        for e in collection
          id = e[idProperty]
          if ids[id]?
            throw new Error(
              "Duplicate #{type} #{id} in config."
            )
          ids[id] = yes

      checkForDublicate("plugin", config.plugins, 'plugin')
      checkForDublicate("device", config.devices, 'id')
      checkForDublicate("rules", config.rules, 'id')
      checkForDublicate("variables", config.variables, 'name')
      checkForDublicate("groups", config.groups, 'id')
      checkForDublicate("pages", config.pages, 'id')

      # Check groups, rules, variables, pages integrity
      logWarning = (type, id, name, collection = "group") ->
        env.logger.warn(
          """Could not find a #{type} with the ID "#{id}" from """ +
          """#{collection} "#{name}" in #{type}s config section."""
        )

      for group in config.groups
        for deviceId in group.devices
          found = _.find(config.devices, {id: deviceId})
          unless found?
            logWarning('device', deviceId, group.id)
        for ruleId in group.rules
          found = _.find(config.rules, {id: ruleId})
          unless found?
            logWarning('rule', ruleId, group.id)
        for variableName in group.variables
          found = _.find(config.variables, {name: variableName})
          unless found?
            logWarning('variable', variableName, group.id)

      for page in config.pages
        for item in page.devices
          found = _.find(config.devices, {id: item.deviceId})
          unless found?
            logWarning('device', item.deviceId, page.id, 'page')

    _setupExpressApp: () ->
      # Setup express
      # -------------
      @app = express()
      @app.use(methodOverride('X-HTTP-Method-Override'))
      @app.use(connectTimeout("5min", respond: false))
      extraHeaders = {}
      @corsEnabled = @config.settings.cors? and not _.isEmpty(@config.settings.cors.allowedOrigin)
      if @corsEnabled
        extraHeaders["Access-Control-Allow-Origin"] = @config.settings.cors.allowedOrigin
        extraHeaders["Access-Control-Allow-Credentials"] = true
        extraHeaders["Access-Control-Allow-Methods"] = "GET,PUT,POST,DELETE"
        extraHeaders["Access-Control-Allow-Headers"] = "Content-Type, Authorization"

      @app.use( (req, res, next) =>
        for own key, value of extraHeaders
          res.header(key, value)

        if @corsEnabled and req.method is 'OPTIONS'
          return res.sendStatus 200

        req.on("timeout", =>
          env.logger.warn(
            "http request handler timeout. Possible unhandled request:
            #{req.method} #{req.url}"
          )
          env.logger.debug(req.body) if req.body?
        )
        next()
      )
      #@app.use express.logger()
      @app.use cookieParser()
      @app.use bodyParser.urlencoded(limit: '10mb', extended: true)
      @app.use bodyParser.json(limit: '10mb')
      auth = @config.settings.authentication
      validSecret = (
        auth.secret? and typeof auth.secret is "string" and auth.secret.length >= 32
      )
      unless validSecret
        auth.secret = require('crypto').randomBytes(64).toString('base64')

      assert typeof auth.secret is "string"
      assert auth.secret.length >= 32

      @app.use cookieSession({
        secret:  auth.secret
        key: 'pimatic.sess'
        cookie: { maxAge: null }
      })

      # Setup authentication
      # ----------------------
      # Use http-basicAuth if authentication is not disabled.

      assert auth.enabled in [yes, no]

      if auth.enabled is yes
        for user in @config.users
          #Check authentication.
          validUsername = (
            user.username? and typeof user.username is "string" and user.username.length isnt 0
          )
          unless validUsername
            throw new Error(
              "Authentication is enabled, but no username has been defined for the user. " +
              "Please define a username in the user section of the config.json file."
            )
          validPassword = (
            user.password? and typeof user.password is "string" and user.password.length isnt 0
          )
          unless validPassword
            throw new Error(
              "Authentication is enabled, but no password has been defined for the user " +
              "\"#{user.username}\". Please define a password for \"#{user.username}\" " +
              "in the users section of the config.json file or disable authentication."
            )

      #req.path
      @app.use( (req, res, next) =>
        if req.path is "/login" then return next()

        # auth is deactivated so we allways continue
        if auth.enabled is no
          req.session.username = ''
          return next()

        if @userManager.isPublicAccessAllowed(req)
          return next()

        # if already logged in so just continue
        loggedIn = (
          typeof req.session.username is "string" and
          typeof req.session.loginToken is "string" and
          req.session.username.length > 0 and
          req.session.loginToken.length > 0 and
          @userManager.checkLoginToken(auth.secret, req.session.username, req.session.loginToken)
        )
        if loggedIn
          return next()

        # else use basic authorization

        unauthorized = (res) =>
          res.set('WWW-Authenticate', 'Basic realm=Authorization Required')
          return res.status(401).send("Unauthorized")

        authInfo = basicAuth(req)

        if !authInfo or !authInfo.name or !authInfo.pass
          return unauthorized(res)

        if @userManager.checkLogin(authInfo.name, authInfo.pass)
          role = @userManager.getUserByUsername(authInfo.name).role
          assert role? and typeof role is "string" and role.length > 0
          req.session.username = authInfo.name
          req.session.loginToken = @userManager.getLoginTokenForUsername(
            auth.secret, authInfo.name
          )
          req.session.role = role
          next()
        else
          delete req.session.username
          delete req.session.loginToken
          delete req.session.role
          unauthorized(res)
      )

      @app.post('/login', (req, res) =>
        user = req.body.username
        password = req.body.password
        rememberMe = req.body.rememberMe
        if rememberMe is 'true' then rememberMe = yes
        if rememberMe is 'false' then rememberMe = no
        rememberMe = !!rememberMe
        if @userManager.checkLogin(user, password)
          role = @userManager.getUserByUsername(user).role
          assert role? and typeof role is "string" and role.length > 0
          req.session.username = user
          req.session.loginToken = @userManager.getLoginTokenForUsername(auth.secret, user)
          req.session.role = role
          req.session.rememberMe = rememberMe
          if rememberMe and auth.loginTime isnt 0
            req.sessionOptions.maxAge = auth.loginTime
          else
            req.sessionOptions.maxAge = null
          #env.logger.info "User login: #{user}"
          res.send({
            success: yes
            username: user
            role: role
            rememberMe: rememberMe
          })
        else
          delete req.session.username
          delete req.session.loginToken
          delete req.session.role
          delete req.session.rememberMe
          env.logger.error "User login failed: Wrong username or password"
          res.status(401).send({
            success: false
            message: __("Wrong username or password.")
          })
      )

      @app.get('/logout', (req, res) =>
        #if req.session?.username?
        #  env.logger.info "User logout: #{req.session.username}"
        req.session = null
        res.status(401).send("You are now logged out.")
        return
      )
      serverEnabled = (
        @config.settings.httpsServer?.enabled or @config.settings.httpServer?.enabled
      )

      unless serverEnabled
        env.logger.warn "You have no HTTPS and no HTTP server enabled!"

      @_initRestApi()

      socketIoPath = '/socket.io'
      engine = new engineIo.Server({path: socketIoPath})
      @io = new socketIo()
      @io.use( (socket, next) =>
        if auth.enabled is no
          return next()
        req = socket.request
        if req.query.username? and req.query.password?
          if @userManager.checkLogin(req.query.username, req.query.password)
            socket.username = req.query.username
            return next()
          else
            return next(new Error('unauthorizied'))
        else if req.session?
          loggedIn = (
            typeof req.session.username is "string" and
            typeof req.session.loginToken is "string" and
            req.session.username.length > 0 and
            req.session.loginToken.length > 0 and
            @userManager.checkLoginToken(
              auth.secret,
              req.session.username,
              req.session.loginToken
            )
          )
          if loggedIn
            socket.username = req.session.username
            return next()
          else
            return next(new Error('Authentication error'))
        else
          return next(new Error('Unauthorized'))
      )

      @io.bind(engine)

      @app.all( '/socket.io/socket.io.js', (req, res) => @io.serve(req, res) )
      @app.all( '/socket.io/*', (req, res) => engine.handleRequest(req, res) )

      @app.use( (err, req, res, next) =>
        env.logger.error("Error on incoming http request to #{req.path}: #{err.message}")
        env.logger.debug(err)
        unless res.headersSent
          res.status(500).send(err.stack)
      )

      onUpgrade = (req, socket, head) =>
        if socketIoPath is req.url.substr(0, socketIoPath.length)
          engine.handleUpgrade(req, socket, head)
        else
          socket.end()
        return

      # Start the HTTPS-server if it is enabled.
      if @config.settings.httpsServer?.enabled
        httpsConfig = @config.settings.httpsServer
        assert httpsConfig instanceof Object
        assert typeof httpsConfig.keyFile is 'string' and httpsConfig.keyFile.length isnt 0
        assert typeof httpsConfig.certFile is 'string' and httpsConfig.certFile.length isnt 0

        httpsOptions = {}
        httpsOptions[name] = value for name, value of httpsConfig
        httpsOptions.key = fs.readFileSync path.resolve(@maindir, '../..', httpsConfig.keyFile)
        httpsOptions.cert = fs.readFileSync path.resolve(@maindir, '../..', httpsConfig.certFile)
        https = require "https"
        @app.httpsServer = https.createServer httpsOptions, @app
        @app.httpsServer.on('upgrade', onUpgrade)

      # Start the HTTP-server if it is enabled.
      if @config.settings.httpServer?.enabled
        http = require "http"
        @app.httpServer = http.createServer @app
        @app.httpServer.on('upgrade', onUpgrade)

      actionsWithBindings = [
        [env.api.framework.actions, this]
        [env.api.rules.actions, @ruleManager]
        [env.api.variables.actions, @variableManager]
        [env.api.plugins.actions, @pluginManager]
        [env.api.database.actions, @database]
        [env.api.groups.actions, @groupManager]
        [env.api.pages.actions, @pageManager]
        [env.api.devices.actions, @deviceManager]
      ]

      onError = (error) =>
        env.logger.error(error.message)
        env.logger.debug(error)

      checkPermissions = (socket, action) =>
        if auth.enabled is no then return true
        hasPermission = no
        if action.permission? and action.permission.scope?
          hasPermission = @userManager.hasPermission(
            socket.username,
            action.permission.scope,
            action.permission.access
          )
        else if action.permission? and action.permission.action?
          hasPermission = @userManager.hasPermissionBoolean(
            socket.username,
            action.permission.action
          )
        else
          hasPermission = yes
        return hasPermission

      @io.on('connection', (socket) =>
        declapi.createSocketIoApi(socket, actionsWithBindings, onError, checkPermissions)

        if auth.enabled is yes
          username = socket.username
          role = @userManager.getUserByUsername(username).role
          permissions = @userManager.getPermissionsByUsername(username)
        else
          username = 'nobody'
          role = 'no'
          permissions = {
            pages: "write"
            rules: "write"
            variables: "write"
            messages: "write"
            events: "write"
            devices: "write"
            groups: "write"
            plugins: "write"
            updates: "write"
            controlDevices: true
            restart: true
          }
        socket.emit('hello', {
          username
          role
          permissions
        })
        if (
          auth.enabled is no or
          @userManager.hasPermission(username, 'devices', 'read') or
          @userManager.hasPermission(username, 'pages', 'read')
        )
          socket.emit('devices', (d.toJson() for d in @deviceManager.getDevices()) )
        else socket.emit('devices', [])

        if auth.enabled is no or @userManager.hasPermission(username, 'rules', 'read')
          socket.emit('rules', (r.toJson() for r in @ruleManager.getRules()) )
        else socket.emit('rules', [])

        if auth.enabled is no or @userManager.hasPermission(username, 'rules', 'read')
          socket.emit('variables', (v.toJson() for v in @variableManager.getVariables()) )
        else socket.emit('variables', [])

        if auth.enabled is no or @userManager.hasPermission(username, 'pages', 'read')
          socket.emit('pages',  @pageManager.getPages(role) )
        else socket.emit('pages', [])

        needsRules = (
          auth.enabled is no or
          @userManager.hasPermission(username, 'devices', 'read') or
          @userManager.hasPermission(username, 'rules', 'read') or
          @userManager.hasPermission(username, 'variables', 'read') or
          @userManager.hasPermission(username, 'pages', 'read') or
          @userManager.hasPermission(username, 'groups', 'read')
        )
        if needsRules
          socket.emit('groups',  @groupManager.getGroups() )
        else socket.emit('groups', [])
      )

    listen: () ->
      genErrFunc = (serverConfig) =>
        return (err) =>
          msg = "Could not listen on port #{serverConfig.port}. Error: #{err.message}. "
          switch err.code
            when "EACCES" then msg += "Are you root?."
            when "EADDRINUSE" then msg += "Is a server already running?"
          env.logger.error msg
          env.logger.debug err.stack
          err.silent = yes
          throw err

      listenPromises = []
      if @app.httpsServer?
        httpsServerConfig = @config.settings.httpsServer
        @app.httpsServer.on 'error', genErrFunc(httpsServerConfig)
        awaiting = Promise.fromCallback( (callback) =>
          @app.httpsServer.listen(httpsServerConfig.port, httpsServerConfig.hostname, callback)
        )
        listenPromises.push awaiting.then( =>
          env.logger.info "Listening for HTTPS-request on port #{httpsServerConfig.port}..."
        )

      if @app.httpServer?
        httpServerConfig = @config.settings.httpServer
        @app.httpServer.on 'error', genErrFunc(@config.settings.httpServer)
        awaiting = Promise.fromCallback( (callback) =>
          if !!httpServerConfig.socket
            httpServer = @app.httpServer
            fs.stat httpServerConfig.socket, (err, stat) ->
              if err
                httpServer.listen(httpServerConfig.socket, callback)
              else
                env.logger.info 'Removing leftover socket.'
                fs.unlink httpServerConfig.socket, (err) ->
                  if err
                    env.logger.info 'Cannot remove socket file, exiting.'
                    process.exit(0)
                  else
                    httpServer.listen(httpServerConfig.socket, callback)
          else
            @app.httpServer.listen(httpServerConfig.port, httpServerConfig.hostname, callback)
        )
        listenPromises.push awaiting.then( =>
          if !!httpServerConfig.socket
            env.logger.info "Listening for HTTP-request on #{httpServerConfig.socket}..."
          else
            env.logger.info "Listening for HTTP-request on port #{httpServerConfig.port}..."
        )

      Promise.all(listenPromises).then( =>
        @emit "server listen", "startup"
      )

    restart: () ->
      daemonized = process.env['PIMATIC_DAEMONIZED']
      unless daemonized?
        throw new Error(
          'Can not restart self, when not daemonized. ' +
            'Please run pimatic with: "node ' + process.argv[1] + ' start" to use this feature.'
        )
      env.logger.info("Restarting...")
      # first we call destroy to be able to release resources allocated by the current process.
      # next, we launch the restart script with the daemonizer, which will send the kill signal
      # to this process
      proxy = new events()
      isRejected = false
      @destroy()
      .catch (err) ->
        isRejected = true
        env.logger.error("Error during orderly shutdown of pimatic: #{err.message}")
        env.logger.debug(err.stack)
      .finally ->
        if daemonized is 'pm2-docker'
          process.exit(unless isRejected then 0 else 1)
        else
          daemon = require 'daemon'
          scriptName = process.argv[1]
          args = process.argv[2..]; args[0] = 'restart'
          child = daemon.daemon(scriptName, args, {cwd: process.cwd()})
          child.on 'error', (error) -> proxy.emit 'error', error
          child.on 'close', (code) -> proxy.emit 'close', code
      # Catch errors executing the restart script
      return new Promise( (resolve, reject) =>
        proxy.on('error', reject)
        proxy.on('close', (code) ->
          if code is 0 then resolve()
          else reject(new Error("Error restarting pimatic, exit code #{code}"))
        )
      ).catch( (err) =>
        env.logger.error("Error restarting pimatic: #{err.message}")
        env.logger.debug(err.stack)
      )


    getGuiSettings: () -> {
      config: @config.settings.gui
      defaults: @config.settings.gui.__proto__
    }

    _emitDeviceAttributeEvent: (device, attributeName, attribute, time, value) ->
      @emit 'deviceAttributeChanged', {device, attributeName, attribute, time, value}
      @io?.emit(
        'deviceAttributeChanged',
        {deviceId: device.id, attributeName, time: time.getTime(), value}
      )


    _emitDeviceEvent: (eventType, device) ->
      @emit(eventType, device)
      @io?.emit(eventType, device.toJson())

    _emitDeviceAdded: (device) -> @_emitDeviceEvent('deviceAdded', device)
    _emitDeviceChanged: (device) -> @_emitDeviceEvent('deviceChanged', device)
    _emitDeviceRemoved: (device) ->
      @_emitDeviceEvent('deviceRemoved', device)

    _emitDeviceOrderChanged: (deviceOrder) ->
      @_emitOrderChanged('deviceOrderChanged', deviceOrder)

    _emitMessageLoggedEvent: (level, msg, meta) ->
      @emit 'messageLogged', {level, msg, meta}
      @io?.emit 'messageLogged', {level, msg, meta}

    _emitOrderChanged: (eventName, order) ->
      @emit(eventName, order)
      @io?.emit(eventName, order)

    _emitPageEvent: (eventType, page) ->
      @emit(eventType, page)
      @io?.emit(eventType, page)

    _emitPageAdded: (page) -> @_emitPageEvent('pageAdded', page)
    _emitPageChanged: (page) -> @_emitPageEvent('pageChanged', page)
    _emitPageRemoved: (page) -> @_emitPageEvent('pageRemoved', page)
    _emitPageOrderChanged: (pageOrder) ->
      @_emitOrderChanged('pageOrderChanged', pageOrder)

    _emitGroupEvent: (eventType, group) ->
      @emit(eventType, group)
      @io?.emit(eventType, group)

    _emitGroupAdded: (group) -> @_emitGroupEvent('groupAdded', group)
    _emitGroupChanged: (group) -> @_emitGroupEvent('groupChanged', group)
    _emitGroupRemoved: (group) -> @_emitGroupEvent('groupRemoved', group)
    _emitGroupOrderChanged: (proupOrder) ->
      @_emitOrderChanged('groupOrderChanged', proupOrder)

    _emitRuleEvent: (eventType, rule) ->
      @emit(eventType, rule)
      @io?.emit(eventType, rule.toJson())

    _emitRuleAdded: (rule) -> @_emitRuleEvent('ruleAdded', rule)
    _emitRuleRemoved: (rule) -> @_emitRuleEvent('ruleRemoved', rule)
    _emitRuleChanged: (rule) -> @_emitRuleEvent('ruleChanged', rule)
    _emitRuleOrderChanged: (ruleOrder) ->
      @_emitOrderChanged('ruleOrderChanged', ruleOrder)

    _emitVariableEvent: (eventType, variable) ->
      @emit(eventType, variable)
      @io?.emit(eventType, variable.toJson())

    _emitVariableAdded: (variable) -> @_emitVariableEvent('variableAdded', variable)
    _emitVariableRemoved: (variable) -> @_emitVariableEvent('variableRemoved', variable)
    _emitVariableChanged: (variable) -> @_emitVariableEvent('variableChanged', variable)
    _emitVariableValueChanged: (variable, value) ->
      @emit("variableValueChanged", variable, value)
      @io?.emit("variableValueChanged", {
        variableName: variable.name
        variableValue: value
      })

    _emitVariableOrderChanged: (variableOrder) ->
      @_emitOrderChanged('variableOrderChanged', variableOrder)

    _emitUpdateProcessStatus: (status, info) ->
      @emit 'updateProcessStatus', status, info
      @io?.emit("updateProcessStatus", {
        status: status
        modules: info.modules
      })

    _emitUpdateProcessMessage: (message, info) ->
      @emit 'updateProcessMessages', message, info
      @io?.emit("updateProcessMessage", {
        message: message
        modules: info.modules
      })

    init: ->

      initVariables = =>
        @variableManager.init()
        @variableManager.on("variableChanged", (changedVar) =>
          for variable in @config.variables
            if variable.name is changedVar.name
              delete variable.value
              delete variable.expression
              switch changedVar.type
                when 'value' then variable.value = changedVar.value
                when 'expression' then variable.expression = changedVar.exprInputStr
              break
          @_emitVariableChanged(changedVar)
          @emit "config"
        )
        @variableManager.on("variableValueChanged", (changedVar, value) =>
          if changedVar.type is 'value'
            for variable in @config.variables
              if variable.name is changedVar.name
                variable.value = value
                break
            @emit "config"
          @_emitVariableValueChanged(changedVar, value)

        )
        @variableManager.on("variableAdded", (addedVar) =>
          switch addedVar.type
            when 'value' then @config.variables.push({
              name: addedVar.name,
              value: addedVar.value
            })
            when 'expression' then @config.variables.push({
              name: addedVar.name,
              expression: addedVar.exprInputStr
            })
          @_emitVariableAdded(addedVar)
          @emit "config"
        )
        @variableManager.on("variableRemoved", (removedVar) =>
          for variable, i in @config.variables
            if variable.name is removedVar.name
              @config.variables.splice(i, 1)
              break
          @_emitVariableRemoved(removedVar)
          @emit "config"
        )

      initDevices = =>
        @deviceManager.on("deviceRemoved", (removedDevice) =>
          for device, i in @config.devices
            if device.id is removedDevice.id
              @config.devices.splice(i, 1)
              break
          @_emitDeviceRemoved(removedDevice)
          @emit "config"
        )
        @deviceManager.on("deviceChanged", (changedDevice) =>
          for device, i in @config.devices
            if device.id is changedDevice.id
              @config.devices[i] = changedDevice.config
              break
          @_emitDeviceChanged(changedDevice)
          @emit "config"
        )


      initActionProvider = =>
        defaultActionProvider = [
          env.actions.SetPresenceActionProvider
          env.actions.ContactActionProvider
          env.actions.SwitchActionProvider
          env.actions.DimmerActionProvider
          env.actions.LogActionProvider
          env.actions.SetVariableActionProvider
          env.actions.ShutterActionProvider
          env.actions.StopShutterActionProvider
          env.actions.ButtonActionProvider
          env.actions.ToggleActionProvider
          env.actions.HeatingThermostatModeActionProvider
          env.actions.HeatingThermostatSetpointActionProvider
          env.actions.TimerActionProvider
          env.actions.AVPlayerPauseActionProvider
          env.actions.AVPlayerStopActionProvider
          env.actions.AVPlayerPlayActionProvider
          env.actions.AVPlayerVolumeActionProvider
          env.actions.AVPlayerNextActionProvider
          env.actions.AVPlayerPrevActionProvider
        ]
        for actProv in defaultActionProvider
          actProvInst = new actProv(this)
          @ruleManager.addActionProvider(actProvInst)

      initPredicateProvider = =>
        defaultPredicateProvider = [
          env.predicates.PresencePredicateProvider
          env.predicates.SwitchPredicateProvider
          env.predicates.DeviceAttributePredicateProvider
          env.predicates.VariablePredicateProvider
          env.predicates.VariableUpdatedPredicateProvider
          env.predicates.ContactPredicateProvider
          env.predicates.ButtonPredicateProvider
          env.predicates.DeviceAttributeWatchdogProvider
          env.predicates.StartupPredicateProvider
        ]
        for predProv in defaultPredicateProvider
          predProvInst = new predProv(this)
          @ruleManager.addPredicateProvider(predProvInst)

      initRules = =>

        addRulePromises = (for rule in @config.rules
          do (rule) =>
            unless rule.active? then rule.active = yes

            unless rule.id.match /^[a-z0-9\-_]+$/i
              newId = S(rule.id).slugify().s
              env.logger.warn """
                The ID of the rule "#{rule.id}" contains a non alphanumeric letter or symbol.
                Changing the ID of the rule to "#{newId}".
              """
              rule.id = newId

            if rule.rule.match /^if .+/
              env.logger.warn """
                Converting old rule "#{rule.id}" from  "if ... then ..." to "when ... then ..."!
              """
              rule.rule = rule.rule.replace(/^if/, "when")

            unless rule.name? then rule.name = S(rule.id).humanize().s

            @ruleManager.addRuleByString(rule.id, {
              name: rule.name,
              ruleString: rule.rule,
              active: rule.active
              logging: rule.logging
            }, force = true).catch( (err) =>
              env.logger.error "Could not parse rule \"#{rule.rule}\": " + err.message
              env.logger.debug err.stack
            )
        )

        return Promise.all(addRulePromises).then(=>
          # Save rule updates to the config file:
          #
          # * If a new rule was added then...
          @ruleManager.on "ruleAdded", (rule) =>
            # ...add it to the rules Array in the config.json file
            inConfig = (_.findIndex(@config.rules , {id: rule.id}) isnt -1)
            unless inConfig
              @config.rules.push {
                id: rule.id
                name: rule.name
                rule: rule.string
                active: rule.active
                logging: rule.logging
              }
            @_emitRuleAdded(rule)
            @emit "config"
          # * If a rule was changed then...
          @ruleManager.on "ruleChanged", (rule) =>
            # ...change the rule with the right id in the config.json file
            @config.rules = for r in @config.rules
              if r.id is rule.id
                {
                  id: rule.id,
                  name: rule.name,
                  rule: rule.string,
                  active: rule.active,
                  logging: rule.logging
                }
              else r
            @_emitRuleChanged(rule)
            @emit "config"
          # * If a rule was removed then
          @ruleManager.on "ruleRemoved", (rule) =>
            # ...Remove the rule with the right ID in the config.json file
            @config.rules = (r for r in @config.rules when r.id isnt rule.id)
            @_emitRuleRemoved(rule)
            @emit "config"
        )

      return @database.init()
        .then( => @pluginManager.checkNpmVersion() )
        .then( => @pluginManager.loadPlugins() )
        .then( => @pluginManager.initPlugins() )
        .then( => @deviceManager.initDevices() )
        .then( => @deviceManager.loadDevices() )
        .then(initVariables)
        .then(initDevices)
        .then(initActionProvider)
        .then(initPredicateProvider)
        .then(initRules)
        .then( =>
          # Save the config on "config" event
          @on "config", =>
            @saveConfig()

          context =
            waitFor: []
            waitForIt: (promise) -> @waitFor.push promise

          @emit "after init", context

          Promise.all(context.waitFor).then => @listen()
        )

    _initRestApi: ->
      auth = @config.settings.authentication

      onError = (error) =>
        if error instanceof Error
          message = error.message
          env.logger.error error.message
          env.logger.debug error.stack

      @app.get("/api", (req, res, nest) => res.send(declapi.stringifyApi(env.api.all)) )
      @app.get("/api/decl-api-client.js", declapi.serveClient)

      createPermissionCheck = (app, actions) =>
        for actionName, action of actions
          do (actionName, action) =>
            if action.rest? and action.permission?
              type = (action.rest.type or 'get').toLowerCase()
              url = action.rest.url
              app[type](url, (req, res, next) =>
                if auth.enabled is yes
                  username = req.session.username
                  if action.permission.scope?
                    hasPermission = @userManager.hasPermission(
                      username,
                      action.permission.scope,
                      action.permission.access
                    )
                  else if action.permission.action?
                    hasPermission = @userManager.hasPermissionBoolean(
                      username,
                      action.permission.action
                    )
                  else
                    throw new Error("Unknown permissions declaration for action #{action}")
                else
                  username = "nobody"
                  hasPermission = yes
                if hasPermission is yes
                  @userManager.requestUsername = username
                  next()
                  @userManager.requestUsername = null
                else
                  res.status(403).send()
              )

      createPermissionCheck(@app, env.api.framework.actions)
      createPermissionCheck(@app, env.api.rules.actions)
      createPermissionCheck(@app, env.api.variables.actions)
      createPermissionCheck(@app, env.api.plugins.actions)
      createPermissionCheck(@app, env.api.database.actions)
      createPermissionCheck(@app, env.api.groups.actions)
      createPermissionCheck(@app, env.api.pages.actions)
      createPermissionCheck(@app, env.api.devices.actions)
      declapi.createExpressRestApi(@app, env.api.framework.actions, this, onError)
      declapi.createExpressRestApi(@app, env.api.rules.actions, this.ruleManager, onError)
      declapi.createExpressRestApi(@app, env.api.variables.actions, this.variableManager, onError)
      declapi.createExpressRestApi(@app, env.api.plugins.actions, this.pluginManager, onError)
      declapi.createExpressRestApi(@app, env.api.database.actions, this.database, onError)
      declapi.createExpressRestApi(@app, env.api.groups.actions, this.groupManager, onError)
      declapi.createExpressRestApi(@app, env.api.pages.actions, this.pageManager, onError)
      declapi.createExpressRestApi(@app, env.api.devices.actions, this.deviceManager, onError)

    getConfig: (password) ->
      #blank passwords
      blankSecrets = (schema, obj) ->
        switch schema.type
          when "object"
            if schema.properties?
              for n, p of schema.properties
                if p.secret and obj[n]?
                  obj[n] = 'xxxxxxxxxx'
                blankSecrets(p, obj[n]) if obj[n]?
          when "array"
            if schema.items? and obj?
              for e in obj
                blankSecrets schema.items, e
      schema = require("../config-schema")
      configCopy = _.cloneDeep(@config)
      delete configCopy['//']
      assert @userManager.requestUsername
      if password?
        unless typeof password is "string"
          throw new Error("Password is not a string")
        unless @userManager.checkLogin(@userManager.requestUsername, password)
          throw new Error("Invalid password")
      else
        blankSecrets schema, configCopy
      return configCopy

    updateConfig: (config) ->
      schema = require("../config-schema")
      @_normalizeScheme(schema)
      @_validateConfig(config, schema)
      assert Array.isArray config.plugins
      assert Array.isArray config.devices
      assert Array.isArray config.pages
      assert Array.isArray config.groups
      @_checkConfig(config)

      for pConf in config.plugins
        fullPluginName = "pimatic-#{pConf.plugin}"
        packageInfo = null
        try
          packageInfo = @pluginManager.getInstalledPackageInfo(fullPluginName)
        catch err
          env.logger.warn(
            "Could not open package.json for \"#{fullPluginName}\": #{err.message} " +
            "Could not validate config."
          )
          continue
        if packageInfo?.configSchema?
          pathToSchema = path.resolve(
            @pluginManager.pathToPlugin(fullPluginName),
            packageInfo.configSchema
          )
          pluginConfigSchema = require(pathToSchema)
          @_normalizeScheme(pluginConfigSchema)
          @_validateConfig(pConf, pluginConfigSchema, "config of #{fullPluginName}")
        else
          env.logger.warn(
            "package.json of \"#{fullPluginName}\" has no \"configSchema\" property. " +
            "Could not validate config."
          )

      for deviceConfig in config.devices
        classInfo = @deviceManager.deviceClasses[deviceConfig.class]
        unless classInfo?
          env.logger.debug("Unknown device class \"#{deviceConfig.class}\"")
          continue
        warnings = []
        classInfo.prepareConfig(deviceConfig) if classInfo.prepareConfig?
        @_normalizeScheme(classInfo.configDef)
        @_validateConfig(
          deviceConfig,
          classInfo.configDef,
            "config of device #{deviceConfig.id}"
        )

      @config = config
      @saveConfig()
      @restart()
      return

    destroy: ->
      if @_destroying? then return @_destroying
      if @app.httpServer?
        httpServerConfig = @config.settings.httpServer
        if !!httpServerConfig.socket
          httpServer = @app.httpServer
          fs.stat httpServerConfig.socket, (err, stat) ->
            if not err
              env.logger.info 'Removing socket...'
              fs.unlink httpServerConfig.socket, (err) ->
                if err
                  env.logger.info 'Cannot remove socket file.'
      return @_destroying = Promise.resolve().then( =>
        context =
          waitFor: []
          waitForIt: (promise) -> @waitFor.push promise

        @emit "destroy", context
        @saveConfig()
        return Promise.all(context.waitFor)
      )


    saveConfig: ->
      assert @config?
      try
        fs.writeFileSync @configFile, JSON.stringify(@config, null, 2)
      catch err
        env.logger.error "Could not write config file: ", err.message
        env.logger.debug err
        env.logger.info "config.json updated"

  return { Framework }
