text: const async = require('async')
  const Util = require('./Util')
  
  const Asset = require('./Asset')
  const Audio = require('./Audio')
  const Entity = require('./Entity')
  
  const HasEventsMixin = require('./HasEventsMixin')
  const HasEntitiesMixin = require('./HasEntitiesMixin')
  const HasScenesMixin = require('./HasScenesMixin')
  
  /**
   * GameEngine is the base class for the top level container, mapping the game into a browser.
   * You should extend GameEngine to implement your own game.
   * @extends HasEventsMixin
   * @mixes HasScenesMixin
   * @mixes HasEntitiesMixin
   * @property {!string} targetId The DOM id of the element to target. This element will be replaced with a HTML5 'canvas' element.
   * @property {boolean} fullscreen Allow the canvas element to occupy the maximum possible space (optional, default false)
   * @property {boolean} showHUD Show the debug Heads Up Display (HUD)  (optional, default false)
   * @property {integer} x The viewport x-coordinate in pixels (optional, default 0)
   * @property {integer} y The viewport y-coordinate in pixels (optional, default 0)
   * @property {float} scale The current scale (zoom) where 1 = 100% (optional, default 1)
   * @property {integer} minX The minimum viewport x-coordinate in pixels (optional)
   * @property {integer} minY The minimum viewport y-coordinate in pixels (optional)
   * @property {integer} maxX The maximum viewport x-coordinate in pixels (optional)
   * @property {integer} maxY The maximum viewport y-coordinate in pixels (optional)
   * @property {float} globalAlpha The global alpha value (0 to 1) for drawing scenes (optional, default 1)
   * @property {boolean} enableScroll Enable mouse scrolling (optional, default true)
   * @property {boolean} enableZoom Enable mouse zooming (optional, default true)
   * @property {integer} width The width of the viewport in pixels (readonly)
   * @property {integer} height The height of the viewport in pixels (readonly)
   */
  class GameEngine {
    /**
  	* Create a new GameEngine with the specified options.
  	* @param {object} options The options for the GameEngine, composed of the properties.
  
  	* @example <caption>To create a GameEngine attached to a specified element in HTML5</caption>
  <div id='game' width="1200" height="960"></div>
  <script type="text/javascript">
  	const GameEngine = require('GameEngine')
  	var game = new GameEngine({ targetId: 'game', fullscreen: true, showHUD: true });
  	game.start();
  </script>
  	*/
    constructor (options = {}) {
      // Has Events
      HasEventsMixin(this, options)
  
      // Has a collection of Scenes
      HasScenesMixin(this, options)
  
      // Has a collection of Entities
      HasEntitiesMixin(this, options)
  
      // targetId is the DOM id of the element to target. This element will be replaced with a HTML5 'canvas' element.
      this.targetId = options.targetId
      // If fullscreen is true, the element will maximise as much as possible
      this.fullscreen = !!options.fullscreen
  
      // Show the Debug HUD
      this.showHUD = !!options.showHUD
  
      // The initial scale where 1 is 100%
      this.scale = typeof (options.scale) === 'undefined' ? 1 : options.scale
  
      // The initial coordinates for scrolling
      this.x = options.x || 0
      this.y = options.y || 0
  
      // The min/max scale values when zoom is enabled
      this.maxScale = options.maxScale
      this.minScale = options.minScale
  
      // The min/max scroll values when scroll is enabled
      this.minX = options.minX
      this.minY = options.minY
      this.maxX = options.maxX
      this.maxY = options.maxY
  
      // Global Alpha
      this.globalAlpha = typeof (options.globalAlpha) === 'undefined' ? 1 : options.globalAlpha
  
      // Enable mouse scrolling/zooming
      this.enableScroll = typeof (options.enableScroll) === 'undefined' ? true : !!options.enableScroll
      this.enableZoom = typeof (options.enableZoom) === 'undefined' ? true : !!options.enableZoom
  
      // Private properties
      this._audioDefs = {}
      this._assetDefs = {}
  
      // Events
      this.defineEvents(['running', 'mouseup', 'mousedown', 'mousemove', 'resize'])
    }
  
    /**
  	* Start the game, loading all {@link Asset}s and {@link Audio}s defined by [addAsset()]{@link GameEngine#addAsset} and [addAudio()]{@link GameEngine#addAudio}, bind to the HTML element, call [init()]{@link GameEngine#init} and start the renderer.
  	*
  	* @param {function} callback The callback function to invoke when the game has been started (optional)
  	*/
    start (callback) {
      console.debug('starting')
      if (this.processKey) {
        document.onkeyup = (e) => { this.processKey(e) }
      }
  
      async.series([
        // Load Assets
        (cb) => {
          async.parallel([
            // Load Assets
            (cb2) => { this.loadAssets(cb2) },
            // Load Audio
            (cb2) => { this.loadAudio(cb2) }
          ], cb)
        },
        // Boot Element
        (cb) => { this.bootElement(cb) },
        // Init
        (cb) => { this.init(cb) }
      ],
      (err) => {
        if (err) {
          console.error('error while starting', err)
          if (callback) callback(err)
          return
        }
        this.running = true
  
        setImmediate(() => { this.redraw(); this._tick() })
        console.debug('started')
        this.trigger('running', this)
        if (callback) callback()
      })
    }
  
    /**
  	* Set the global alpha for drawing.
  	* @param {float} ga The Global alpha value 0 - 1
  	*/
    setGlobalAlpha (ga) {
      this.globalAlpha = ga
    }
  
    /**
  	* Init is called after asset and audio loading. You should override init to create your Scenes, Entities and other game objects.
  	*
  	* Note: If you override init, you *must* call the callback when done.
  	* @param {function} callback The callback function to invoke when the init has been completed
  	*/
    init (callback) {
      console.debug('init')
      callback()
    }
  
    /**
  	* Stop the game engine.
  	*/
    stop () {
      this.running = false
    }
  
    /**
  	* Add an asset definition. Note that the {@link Asset} resource will not be created until [start()]{@link GameEngine#start} is called.
  	* @param {String} name Asset Name
  	* @param {String} src Source filename
  	*/
    addAsset (name, src) {
      this._assetDefs[name] = src
    }
  
    /**
  	* Get the {@link Asset} with the specified name.
  	* @param {String} name Asset Name
  	* @returns {Asset} The Asset with the specified name, or null
  	* @throws {Exception} Will throw 'Asset not found' if the Asset has not been loaded
  	*/
    getAsset (name) {
      if (!this.assets[name]) throw 'Asset not found: ' + name
      return this.assets[name]
    }
  
    /**
  	* Add an audio definition. Note that the {@link Audio} resource will not be created until [start()]{@link GameEngine#start} is called.
  	* @param {String} name Audio Name
  	* @param {String} src Source filename
  	* @param {String} type MIME type
  	*/
    addAudio (name, src, type) {
      this._audioDefs[name] = { src: src, type: type }
    }
  
    /**
  	* Get the {@link Audio} with the specified name.
  	* @param {String} name Audio Name
  	* @returns {Audio} The Audio with the specified name, or null
  	* @throws {Exception} Will throw 'Audio not found' if the Audio has not been loaded
  	*/
    getAudio (name) {
      if (!this.audio[name]) throw 'Audio not found: ' + name
      return this.audio[name]
    }
  
    /**
  	* Mark the whole GameEngine to be redrawn.
  	*/
    redraw () {
      this._doredraw = true
  
      // Redraw All Scenes
      this.redrawScenes()
  
      // Redraw All Entities
      this.redrawEntities()
    }
  
    /**
  	* Create and Load all defined {@link Asset}s.
  	* @param {function} callback The callback function to invoke when all assets have been loaded (optional)
  	*/
    loadAssets (callback) {
      console.debug('loading assets')
      this.assets = {}
      for (var i in this._assetDefs) {
        this.assets[i] = new Asset({ name: i, src: this._assetDefs[i] })
      }
  
      async.each(this.assets, (asset, cb) => { asset.load(cb) }, callback)
    }
  
    /**
  	* Create and Load all defined {@link Audio}s.
  	* @param {function} callback The callback function to invoke when all assets have been loaded (optional)
  	*/
    loadAudio (callback) {
      console.debug('loading audio')
      this.audio = {}
      for (var i in this._audioDefs) {
        this.audio[i] = new Audio({ name: i, src: this._audioDefs[i].src, type: this._audioDefs[i].type })
      }
  
      async.each(this.audio, (audio, cb) => { audio.load(cb) }, callback)
    }
  
    /**
  	* Boot the GameEngine into an HTML 'canvas' element, and replace the DOM element specified by {@link GameEngine.targetId} with it
  	* @param {function} callback The callback function to invoke when the element has been replaced (optional)
  	*/
    bootElement (callback) {
      this.target = document.getElementById(this.targetId)
      this.element = document.createElement('canvas')
      this.document = this.target.ownerDocument
      this.window = this.document.defaultView || this.document.parentWindow
  
      if (this.fullscreen) {
        this.recomputeFullScreen()
        this.window.addEventListener('resize', Util.debounce(() => { this.recomputeFullScreen() }, 100))
      } else {
        this.element.width = this.target.getAttribute('width')
        this.element.height = this.target.getAttribute('height')
      }
  
      this.element.classList.add('gamescreen')
      this.target.parentNode.replaceChild(this.element, this.target)
  
      this.width = this.element.width / this.scale
      this.height = this.element.height / this.scale
  
      this._bindMouseWheel()
  
      if (callback) callback()
    }
  
    /**
  	* Recompute the [width]{@link GameEngine.width} and [height]{@link GameEngine.height} from the 'canvas' element dimensions and call [redraw()]{@link GameEngine#redraw}
  	*/
    recomputeFullScreen () {
      this.element.width = this.window.innerWidth
      this.element.height = this.window.innerHeight
      this.width = Math.round(this.element.width / this.scale)
      this.height = Math.round(this.element.height / this.scale)
      this.redraw()
      this.trigger('resize', this)
    }
  
    _bindMouseWheel () {
      var ael = this.element.addEventListener
      if (!ael) ael = this.element.attachEvent
  
      if (this.enableScroll || this.enableZoom) {
        ael('mousewheel', (e) => this._panZoom(e), false)
        ael('DOMMouseScroll', (e) => this._panZoom(e), false)
      }
  
      ael('mousemove', (e) => this._move(e), false)
      ael('mousedown', (e) => this.trigger('mousedown', this, e))
      ael('mouseup', (e) => this.trigger('mouseup', this, e))
    }
  
    _tick () {
      var context = this.element.getContext('2d')
  
      this.redraw()
  
      context.fillStyle = 'black'
      context.fillRect(0, 0, this.element.width, this.element.height)
  
      context.save()
  
      // Set Alpha, scale
      context.scale(this.scale, this.scale)
      context.translate(this.x, this.y)
      context.globalAlpha = this.globalAlpha
  
      // Ensure TiledScene sort order
      if (!this._sceneOrderMap) this.sortScenesZ()
  
      // Draw all the scenes in z-order
      this.drawScenes(context)
  
      // Draw all the entities in z-order
      this.drawEntities(context)
  
      context.restore()
  
      // HUD
      if (this.showHUD && this._doredraw) { this._drawHUD(context) }
  
      this._doredraw = false
  
      if (this.running) window.requestAnimationFrame(this._tick.bind(this), 0)
    }
  
    // Event Handlers
    _panZoom (e) {
      if (e.shiftKey && this.enableZoom) {
        var f = e.deltaY / 100
        this.scale = this.scale + f
  
        if (this.minScale) this.scale = Math.max(this.scale, this.minScale)
        if (this.maxScale) this.scale = Math.min(this.scale, this.maxScale)
  
        // Update Width/Height
        var ow = this.width; var oh = this.height
        this.width = this.element.width / this.scale
        this.height = this.element.height / this.scale
  
        // Update x and y to centre zoom
        this.x = this.x - (ow - this.width) / 2
        this.y = this.y - (oh - this.height) / 2
      } else if (this.enableScroll) {
        this.x += e.deltaX
        this.y += e.deltaY
      }
  
      // Limits?
      this._enforceScrollLimits()
  
      // Correct Mouse Coords
      this._setMouseCoords(e)
  
      // Redraw Everything
      this.redraw()
  
      // Prevent DOM Stuff
      e.preventDefault()
      e.stopPropagation()
    }
  
    _enforceScrollLimits () {
      if (this.minX) this.x = Math.max(this.minX * this.scale, this.x)
      if (this.minY) this.y = Math.max(this.minY * this.scale, this.y)
      if (this.maxX) this.x = Math.min(this.maxX / this.scale, this.x)
      if (this.maxY) this.y = Math.min(this.maxY / this.scale, this.y)
    }
  
    _move (e) {
      this._setMouseCoords(e)
      this.redraw()
  
      this.trigger('mousemove', this, e)
  
      // Prevent DOM Stuff
      e.preventDefault()
      e.stopPropagation()
    }
  
    _setMouseCoords (e) {
      this.mouseX = (e.x / this.scale) - this.x
      this.mouseY = (e.y / this.scale) - this.y
    }
  
    _drawHUD (context) {
      context.save()
      context.font = '14px Arial'
      context.fillStyle = 'white'
      context.fillText(
        'Screen (X: ' + Math.round(this.x) +
  			' Y: ' + Math.round(this.y) +
  			' W: ' + Math.round(this.width) +
  			' H: ' + Math.round(this.height) + ')' +
  			' Zoom: ' + Math.round(this.scale * 100) + '%' +
  			' Mouse (X: ' + Math.round(this.mouseX) + ' Y: ' + Math.round(this.mouseY) + ')' +
  			' Limits Min: (X: ' + Math.round(this.minX / this.scale) + ', Y: ' + Math.round(this.minY / this.scale) + ')' +
  			' Limit Max: (X: ' + Math.round(this.maxX * this.scale) + ', Y: ' + Math.round(this.maxY * this.scale) + ')'
        , 10, 20)
      context.restore()
    }
  }
  
  module.exports = GameEngine
  