'use strict'

module.exports = createScene

var createCamera = require('3d-view-controls')
var createAxes   = require('gl-axes3d')
var axesRanges   = require('gl-axes3d/properties')
var createSpikes = require('gl-spikes3d')
var createSelect = require('gl-select-static')
var createFBO    = require('gl-fbo')
var drawTriangle = require('a-big-triangle')
var mouseChange  = require('mouse-change')
var perspective  = require('gl-mat4/perspective')
var createShader = require('./lib/shader')
var isMobile = require('is-mobile')()

function MouseSelect() {
  this.mouse          = [-1,-1]
  this.screen         = null
  this.distance       = Infinity
  this.index          = null
  this.dataCoordinate = null
  this.dataPosition   = null
  this.object         = null
  this.data           = null
}

function getContext(canvas, options) {
  var gl = null
  try {
    gl = canvas.getContext('webgl', options)
    if(!gl) {
      gl = canvas.getContext('experimental-webgl', options)
    }
  } catch(e) {
    return null
  }
  return gl
}

function roundUpPow10(x) {
  var y = Math.round(Math.log(Math.abs(x)) / Math.log(10))
  if(y < 0) {
    var base = Math.round(Math.pow(10, -y))
    return Math.ceil(x*base) / base
  } else if(y > 0) {
    var base = Math.round(Math.pow(10, y))
    return Math.ceil(x/base) * base
  }
  return Math.ceil(x)
}

function defaultBool(x) {
  if(typeof x === 'boolean') {
    return x
  }
  return true
}

function createScene(options) {
  options = options || {}

  var stopped = false

  var pixelRatio = options.pixelRatio || parseFloat(window.devicePixelRatio)

  var canvas = options.canvas
  if(!canvas) {
    canvas = document.createElement('canvas')
    if(options.container) {
      var container = options.container
      container.appendChild(canvas)
    } else {
      document.body.appendChild(canvas)
    }
  }

  var gl = options.gl
  if(!gl) {
    gl = getContext(canvas,
      options.glOptions || {
        premultipliedAlpha: true,
        antialias: true
      })
  }
  if(!gl) {
    throw new Error('webgl not supported')
  }

  //Initial bounds
  var bounds = options.bounds || [[-10,-10,-10], [10,10,10]]

  //Create selection
  var selection = new MouseSelect()

  //Accumulation buffer
  var accumBuffer = createFBO(gl,
    [gl.drawingBufferWidth, gl.drawingBufferHeight], {
      preferFloat: !isMobile
    })

  var accumShader = createShader(gl)

  //Create a camera
  var cameraOptions = options.camera || {
    eye:    [2,0,0],
    center: [0,0,0],
    up:     [0,1,0],
    zoomMin: 0.1,
    zoomMax: 100,
    mode:    'turntable'
  }

  //Create axes
  var axesOptions = options.axes || {}
  var axes = createAxes(gl, axesOptions)
  axes.enable = !axesOptions.disable

  //Create spikes
  var spikeOptions = options.spikes || {}
  var spikes = createSpikes(gl, spikeOptions)

  //Object list is empty initially
  var objects         = []
  var pickBufferIds   = []
  var pickBufferCount = []
  var pickBuffers     = []

  //Dirty flag, skip redraw if scene static
  var dirty       = true
  var pickDirty   = true

  var projection     = new Array(16)
  var model          = new Array(16)

  var cameraParams = {
    view:         null,
    projection:   projection,
    model:        model
  }

  var pickDirty = true

  var viewShape = [ gl.drawingBufferWidth, gl.drawingBufferHeight ]

  //Create scene object
  var scene = {
    gl:           gl,
    contextLost:  false,
    pixelRatio:   options.pixelRatio || parseFloat(window.devicePixelRatio),
    canvas:       canvas,
    selection:    selection,
    camera:       createCamera(canvas, cameraOptions),
    axes:         axes,
    axesPixels:   null,
    spikes:       spikes,
    bounds:       bounds,
    objects:      objects,
    shape:        viewShape,
    aspect:       options.aspectRatio || [1,1,1],
    pickRadius:   options.pickRadius || 10,
    zNear:        options.zNear || 0.01,
    zFar:         options.zFar  || 1000,
    fovy:         options.fovy  || Math.PI/4,
    clearColor:   options.clearColor || [0,0,0,0],
    autoResize:   defaultBool(options.autoResize),
    autoBounds:   defaultBool(options.autoBounds),
    autoScale:    !!options.autoScale,
    autoCenter:   defaultBool(options.autoCenter),
    clipToBounds: defaultBool(options.clipToBounds),
    snapToData:   !!options.snapToData,
    onselect:     options.onselect || null,
    onrender:     options.onrender || null,
    onclick:      options.onclick  || null,
    cameraParams: cameraParams,
    oncontextloss: null,
    mouseListener: null
  }

  var pickShape = [ (gl.drawingBufferWidth/scene.pixelRatio)|0, (gl.drawingBufferHeight/scene.pixelRatio)|0 ]

  function resizeListener() {
    if(stopped) {
      return
    }
    if(!scene.autoResize) {
      return
    }
    var parent = canvas.parentNode
    var width  = 1
    var height = 1
    if(parent && parent !== document.body) {
      width  = parent.clientWidth
      height = parent.clientHeight
    } else {
      width  = window.innerWidth
      height = window.innerHeight
    }
    var nextWidth  = Math.ceil(width  * scene.pixelRatio)|0
    var nextHeight = Math.ceil(height * scene.pixelRatio)|0
    if(nextWidth !== canvas.width || nextHeight !== canvas.height) {
      canvas.width   = nextWidth
      canvas.height  = nextHeight
      var style = canvas.style
      style.position = style.position || 'absolute'
      style.left     = '0px'
      style.top      = '0px'
      style.width    = width  + 'px'
      style.height   = height + 'px'
      dirty = true
    }
  }
  if(scene.autoResize) {
    resizeListener()
  }
  window.addEventListener('resize', resizeListener)

  function reallocPickIds() {
    var numObjs = objects.length
    var numPick = pickBuffers.length
    for(var i=0; i<numPick; ++i) {
      pickBufferCount[i] = 0
    }
    obj_loop:
    for(var i=0; i<numObjs; ++i) {
      var obj = objects[i]
      var pickCount = obj.pickSlots
      if(!pickCount) {
        pickBufferIds[i] = -1
        continue
      }
      for(var j=0; j<numPick; ++j) {
        if(pickBufferCount[j] + pickCount < 255) {
          pickBufferIds[i] = j
          obj.setPickBase(pickBufferCount[j]+1)
          pickBufferCount[j] += pickCount
          continue obj_loop
        }
      }
      //Create new pick buffer
      var nbuffer = createSelect(gl, viewShape)
      pickBufferIds[i] = numPick
      pickBuffers.push(nbuffer)
      pickBufferCount.push(pickCount)
      obj.setPickBase(1)
      numPick += 1
    }
    while(numPick > 0 && pickBufferCount[numPick-1] === 0) {
      pickBufferCount.pop()
      pickBuffers.pop().dispose()
    }
  }

  scene.update = function(options) {
    if(stopped) {
      return
    }
    options = options || {}
    dirty = true
    pickDirty = true
  }

  scene.add = function(obj) {
    if(stopped) {
      return
    }
    obj.axes = axes
    objects.push(obj)
    pickBufferIds.push(-1)
    dirty = true
    pickDirty = true
    reallocPickIds()
  }

  scene.remove = function(obj) {
    if(stopped) {
      return
    }
    var idx = objects.indexOf(obj)
    if(idx < 0) {
      return
    }
    objects.splice(idx, 1)
    pickBufferIds.pop()
    dirty = true
    pickDirty = true
    reallocPickIds()
  }

  scene.dispose = function() {
    if(stopped) {
      return
    }

    stopped = true

    window.removeEventListener('resize', resizeListener)
    canvas.removeEventListener('webglcontextlost', checkContextLoss)
    scene.mouseListener.enabled = false

    if(scene.contextLost) {
      return
    }

    //Destroy objects
    axes.dispose()
    spikes.dispose()
    for(var i=0; i<objects.length; ++i) {
      objects[i].dispose()
    }

    //Clean up buffers
    accumBuffer.dispose()
    for(var i=0; i<pickBuffers.length; ++i) {
      pickBuffers[i].dispose()
    }

    //Clean up shaders
    accumShader.dispose()

    //Release all references
    gl = null
    axes = null
    spikes = null
    objects = []
  }

  //Update mouse position
  var mouseRotating = false

  var prevButtons = 0

  scene.mouseListener = mouseChange(canvas, function(buttons, x, y) {
    if(stopped) {
      return
    }

    var numPick = pickBuffers.length
    var numObjs = objects.length
    var prevObj = selection.object

    selection.distance = Infinity
    selection.mouse[0] = x
    selection.mouse[1] = y
    selection.object = null
    selection.screen = null
    selection.dataCoordinate = selection.dataPosition = null

    var change = false

    if(buttons && prevButtons) {
      mouseRotating = true
    } else {
      if(mouseRotating) {
        pickDirty = true
      }
      mouseRotating = false

      for(var i=0; i<numPick; ++i) {
        var result = pickBuffers[i].query(x, pickShape[1] - y - 1, scene.pickRadius)
        if(result) {
          if(result.distance > selection.distance) {
            continue
          }
          for(var j=0; j<numObjs; ++j) {
            var obj = objects[j]
            if(pickBufferIds[j] !== i) {
              continue
            }
            var objPick = obj.pick(result)
            if(objPick) {
              selection.buttons        = buttons
              selection.screen         = result.coord
              selection.distance       = result.distance
              selection.object         = obj
              selection.index          = objPick.distance
              selection.dataPosition   = objPick.position
              selection.dataCoordinate = objPick.dataCoordinate
              selection.data           = objPick
              change = true
            }
          }
        }
      }
    }

    if(prevObj && prevObj !== selection.object) {
      if(prevObj.highlight) {
        prevObj.highlight(null)
      }
      dirty = true
    }
    if(selection.object) {
      if(selection.object.highlight) {
        selection.object.highlight(selection.data)
      }
      dirty = true
    }

    change = change || (selection.object !== prevObj)
    if(change && scene.onselect) {
      scene.onselect(selection)
    }

    if((buttons & 1) && !(prevButtons & 1) && scene.onclick) {
      scene.onclick(selection)
    }
    prevButtons = buttons
  })

  function checkContextLoss() {
    if(scene.contextLost) {
      return true
    }
    if(gl.isContextLost()) {
      scene.contextLost = true
      scene.mouseListener.enabled = false
      scene.selection.object = null
      if(scene.oncontextloss) {
        scene.oncontextloss()
      }
    }
  }

  canvas.addEventListener('webglcontextlost', checkContextLoss)

  //Render the scene for mouse picking
  function renderPick() {
    if(checkContextLoss()) {
      return
    }

    gl.colorMask(true, true, true, true)
    gl.depthMask(true)
    gl.disable(gl.BLEND)
    gl.enable(gl.DEPTH_TEST)

    var numObjs = objects.length
    var numPick = pickBuffers.length
    for(var j=0; j<numPick; ++j) {
      var buf = pickBuffers[j]
      buf.shape = pickShape
      buf.begin()
      for(var i=0; i<numObjs; ++i) {
        if(pickBufferIds[i] !== j) {
          continue
        }
        var obj = objects[i]
        if(obj.drawPick) {
          obj.pixelRatio = 1
          obj.drawPick(cameraParams)
        }
      }
      buf.end()
    }
  }

  var nBounds = [
    [ Infinity, Infinity, Infinity],
    [-Infinity,-Infinity,-Infinity]]

  var prevBounds = [nBounds[0].slice(), nBounds[1].slice()]

  function redraw() {
    if(checkContextLoss()) {
      return
    }

    resizeListener()

    //Tick camera
    var cameraMoved = scene.camera.tick()
    cameraParams.view = scene.camera.matrix
    dirty     = dirty || cameraMoved
    pickDirty = pickDirty || cameraMoved

      //Set pixel ratio
    axes.pixelRatio   = scene.pixelRatio
    spikes.pixelRatio = scene.pixelRatio

    //Check if any objects changed, recalculate bounds
    var numObjs = objects.length
    var lo = nBounds[0]
    var hi = nBounds[1]
    lo[0] = lo[1] = lo[2] =  Infinity
    hi[0] = hi[1] = hi[2] = -Infinity
    for(var i=0; i<numObjs; ++i) {
      var obj = objects[i]

      //Set the axes properties for each object
      obj.pixelRatio = scene.pixelRatio
      obj.axes = scene.axes

      dirty = dirty || !!obj.dirty
      pickDirty = pickDirty || !!obj.dirty
      var obb = obj.bounds
      if(obb) {
        var olo = obb[0]
        var ohi = obb[1]
        for(var j=0; j<3; ++j) {
          lo[j] = Math.min(lo[j], olo[j])
          hi[j] = Math.max(hi[j], ohi[j])
        }
      }
    }

    //Recalculate bounds
    var bounds = scene.bounds
    if(scene.autoBounds) {
      for(var j=0; j<3; ++j) {
        if(hi[j] < lo[j]) {
          lo[j] = -1
          hi[j] = 1
        } else {
          if(lo[j] === hi[j]) {
            lo[j] -= 1
            hi[j] += 1
          }
          var padding = 0.05 * (hi[j] - lo[j])
          lo[j] = lo[j] - padding
          hi[j] = hi[j] + padding
        }
        bounds[0][j] = lo[j]
        bounds[1][j] = hi[j]
      }
    }

    var boundsChanged = false
    for(var j=0; j<3; ++j) {
        boundsChanged = boundsChanged ||
            (prevBounds[0][j] !== bounds[0][j])  ||
            (prevBounds[1][j] !== bounds[1][j])
        prevBounds[0][j] = bounds[0][j]
        prevBounds[1][j] = bounds[1][j]
    }

    //Recalculate bounds
    pickDirty = pickDirty || boundsChanged
    dirty = dirty || boundsChanged

    if(!dirty) {
      return
    }

    if(boundsChanged) {
      var tickSpacing = [0,0,0]
      for(var i=0; i<3; ++i) {
        tickSpacing[i] = roundUpPow10((bounds[1][i]-bounds[0][i]) / 10.0)
      }
      if(axes.autoTicks) {
        axes.update({
          bounds: bounds,
          tickSpacing: tickSpacing
        })
      } else {
        axes.update({
          bounds: bounds
        })
      }
    }

    //Get scene
    var width  = gl.drawingBufferWidth
    var height = gl.drawingBufferHeight
    viewShape[0] = width
    viewShape[1] = height
    pickShape[0] = Math.max(width/scene.pixelRatio, 1)|0
    pickShape[1] = Math.max(height/scene.pixelRatio, 1)|0

    //Compute camera parameters
    perspective(projection,
      scene.fovy,
      width/height,
      scene.zNear,
      scene.zFar)

    //Compute model matrix
    for(var i=0; i<16; ++i) {
      model[i] = 0
    }
    model[15] = 1

    var maxS = 0
    for(var i=0; i<3; ++i) {
      maxS = Math.max(maxS, bounds[1][i] - bounds[0][i])
    }

    for(var i=0; i<3; ++i) {
      if(scene.autoScale) {
        model[5*i] = scene.aspect[i] / (bounds[1][i] - bounds[0][i])
      } else {
        model[5*i] = 1  / maxS
      }
      if(scene.autoCenter) {
        model[12+i] = -model[5*i] * 0.5 * (bounds[0][i] + bounds[1][i])
      }
    }

    //Apply axes/clip bounds
    for(var i=0; i<numObjs; ++i) {
      var obj = objects[i]

      //Set axes bounds
      obj.axesBounds = bounds

      //Set clip bounds
      if(scene.clipToBounds) {
        obj.clipBounds = bounds
      }
    }
    //Set spike parameters
    if(selection.object) {
      if(scene.snapToData) {
        spikes.position = selection.dataCoordinate
      } else {
        spikes.position = selection.dataPosition
      }
      spikes.bounds = bounds
    }

    //If state changed, then redraw pick buffers
    if(pickDirty) {
      pickDirty = false
      renderPick()
    }

    //Recalculate pixel data
    scene.axesPixels = axesRanges(scene.axes, cameraParams, width, height)

    //Call render callback
    if(scene.onrender) {
      scene.onrender()
    }

    //Read value
    gl.bindFramebuffer(gl.FRAMEBUFFER, null)
    gl.viewport(0, 0, width, height)

    //General strategy: 3 steps
    //  1. render non-transparent objects
    //  2. accumulate transparent objects into separate fbo
    //  3. composite final scene

    //Clear FBO
    var clearColor = scene.clearColor
    gl.clearColor(clearColor[0], clearColor[1], clearColor[2], clearColor[3])
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
    gl.depthMask(true)
    gl.colorMask(true, true, true, true)
    gl.enable(gl.DEPTH_TEST)
    gl.depthFunc(gl.LEQUAL)
    gl.disable(gl.BLEND)
    gl.disable(gl.CULL_FACE)  //most visualization surfaces are 2 sided

    //Render opaque pass
    var hasTransparent = false
    if(axes.enable) {
      hasTransparent = hasTransparent || axes.isTransparent()
      axes.draw(cameraParams)
    }
    spikes.axes = axes
    if(selection.object) {
      spikes.draw(cameraParams)
    }

    gl.disable(gl.CULL_FACE)  //most visualization surfaces are 2 sided

    for(var i=0; i<numObjs; ++i) {
      var obj = objects[i]
      obj.axes = axes
      obj.pixelRatio = scene.pixelRatio
      if(obj.isOpaque && obj.isOpaque()) {
        obj.draw(cameraParams)
      }
      if(obj.isTransparent && obj.isTransparent()) {
        hasTransparent = true
      }
    }

    if(hasTransparent) {
      //Render transparent pass
      accumBuffer.shape = viewShape
      accumBuffer.bind()
      gl.clear(gl.DEPTH_BUFFER_BIT)
      gl.colorMask(false, false, false, false)
      gl.depthMask(true)
      gl.depthFunc(gl.LESS)

      //Render forward facing objects
      if(axes.enable && axes.isTransparent()) {
        axes.drawTransparent(cameraParams)
      }
      for(var i=0; i<numObjs; ++i) {
        var obj = objects[i]
        if(obj.isOpaque && obj.isOpaque()) {
          obj.draw(cameraParams)
        }
      }

      //Render transparent pass
      gl.enable(gl.BLEND)
      gl.blendEquation(gl.FUNC_ADD)
      gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
      gl.colorMask(true, true, true, true)
      gl.depthMask(false)
      gl.clearColor(0,0,0,0)
      gl.clear(gl.COLOR_BUFFER_BIT)

      if(axes.isTransparent()) {
        axes.drawTransparent(cameraParams)
      }

      for(var i=0; i<numObjs; ++i) {
        var obj = objects[i]
        if(obj.isTransparent && obj.isTransparent()) {
          obj.drawTransparent(cameraParams)
        }
      }

      //Unbind framebuffer
      gl.bindFramebuffer(gl.FRAMEBUFFER, null)

      //Draw composite pass
      gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
      gl.disable(gl.DEPTH_TEST)
      accumShader.bind()
      accumBuffer.color[0].bind(0)
      accumShader.uniforms.accumBuffer = 0
      drawTriangle(gl)

      //Turn off blending
      gl.disable(gl.BLEND)
    }

    //Clear dirty flags
    dirty = false
    for(var i=0; i<numObjs; ++i) {
      objects[i].dirty = false
    }
  }


  //Draw the whole scene
  function render() {
    if(stopped || scene.contextLost) {
      return
    }
    requestAnimationFrame(render)
    redraw()
  }
  render()

  //Force redraw of whole scene
  scene.redraw = function() {
    if(stopped) {
      return
    }
    dirty = true
    redraw()
  }

  return scene
}
