import InfiniteChecker from './lib/infinite-checker'
import Primitives from './lib/primitives'
import { getGlobal } from './helpers'
import esprima from 'esprima'
import hoist from 'hoister'

var maxIterations = 1000000,
  parse = esprima.parse

// 'eval' with a controlled environment
function safeEval(src, parentContext){
  var tree = prepareAst(src)
  var context = Object.create(parentContext || {})
  return finalValue(evaluateAst(tree, context))
}

safeEval.func = FunctionFactory()

// create a 'Function' constructor for a controlled environment
function FunctionFactory(parentContext){
  var context = Object.create(parentContext || {})
  return function Function() {
    // normalize arguments array
    var args = Array.prototype.slice.call(arguments)
    var src = args.slice(-1)[0]
    args = args.slice(0,-1)
    if (typeof src === 'string'){
      //HACK: esprima doesn't like returns outside functions
      src = parse('function a(){ ' + src + '}').body[0].body
    }
    var tree = prepareAst(src)
    return getFunction(tree, args, context)
  }
}

// takes an AST or js source and returns an AST
function prepareAst(src){
  var tree = (typeof src === 'string') ? parse(src) : src
  return hoist(tree)
}

// evaluate an AST in the given context
function evaluateAst(tree, context){

  var safeFunction = FunctionFactory(context)
  var primitives = Primitives(context)

  // block scoped context for catch (ex) and 'let'
  var blockContext = context

  return walk(tree)

  // recursively walk every node in an array
  function walkAll(nodes){
    var result = undefined
    for (var i=0;i<nodes.length;i++){
      var childNode = nodes[i]
      if (childNode.type === 'EmptyStatement') continue
      result = walk(childNode)
      if (result instanceof ReturnValue){
        return result
      }
    }
    return result
  }

  // recursively evalutate the node of an AST
  function walk(node){
    if (!node) return

    switch (node.type) {

      case 'Program':
        return walkAll(node.body )

      case 'BlockStatement':
        enterBlock()
        var result = walkAll(node.body)
        leaveBlock()
        return result

      case 'SequenceExpression':
        return walkAll(node.expressions)

      case 'FunctionDeclaration':
        var params = node.params.map(getName)
        var value = getFunction(node.body, params, blockContext)
        return context[node.id.name] = value

      case 'FunctionExpression':
        var params = node.params.map(getName)
        return getFunction(node.body, params, blockContext)

      case 'ReturnStatement':
        var value = walk(node.argument)
        return new ReturnValue('return', value)

      case 'BreakStatement':
        return new ReturnValue('break')

      case 'ContinueStatement':
        return new ReturnValue('continue')

      case 'ExpressionStatement':
        return walk(node.expression)

      case 'AssignmentExpression':
        return setValue(blockContext, node.left, node.right, node.operator)

      case 'UpdateExpression':
        return setValue(blockContext, node.argument, null, node.operator)

      case 'VariableDeclaration':
        node.declarations.forEach(function(declaration){
          var target = node.kind === 'let' ? blockContext : context
          if (declaration.init){
            target[declaration.id.name] = walk(declaration.init)
          } else {
            target[declaration.id.name] = undefined
          }
        })
        break

      case 'SwitchStatement':
        var defaultHandler = null
        var matched = false
        var value = walk(node.discriminant)
        var result = undefined

        enterBlock()

        var i = 0
        while (result == null){
          if (i<node.cases.length){
            if (node.cases[i].test){ // check or fall through
              matched = matched || (walk(node.cases[i].test) === value)
            } else if (defaultHandler == null) {
              defaultHandler = i
            }
            if (matched){
              var r = walkAll(node.cases[i].consequent)
              if (r instanceof ReturnValue){ // break out
                if (r.type == 'break') break
                result = r
              }
            }
            i += 1 // continue
          } else if (!matched && defaultHandler != null){
            // go back and do the default handler
            i = defaultHandler
            matched = true
          } else {
            // nothing we can do
            break
          }
        }

        leaveBlock()
        return result

      case 'IfStatement':
        if (walk(node.test)){
          return walk(node.consequent)
        } else if (node.alternate) {
          return walk(node.alternate)
        }

      case 'ForStatement':
        var infinite = InfiniteChecker(maxIterations)
        var result = undefined

        enterBlock() // allow lets on delarations
        for (walk(node.init); walk(node.test); walk(node.update)){
          var r = walk(node.body)

          // handle early return, continue and break
          if (r instanceof ReturnValue){
            if (r.type == 'continue') continue
            if (r.type == 'break') break
            result = r
            break
          }

          infinite.check()
        }
        leaveBlock()
        return result

      case 'ForInStatement':
        var infinite = InfiniteChecker(maxIterations)
        var result = undefined

        var value = walk(node.right)
        var property = node.left

        var target = context
        enterBlock()

        if (property.type == 'VariableDeclaration'){
          walk(property)
          property = property.declarations[0].id
          if (property.kind === 'let'){
            target = blockContext
          }
        }

        for (var key in value){
          setValue(target, property, {type: 'Literal', value: key})
          var r = walk(node.body)

          // handle early return, continue and break
          if (r instanceof ReturnValue){
            if (r.type == 'continue') continue
            if (r.type == 'break') break
            result = r
            break
          }

          infinite.check()
        }
        leaveBlock()

        return result

      case 'WhileStatement':
        var infinite = InfiniteChecker(maxIterations)
        while (walk(node.test)){
          walk(node.body)
          infinite.check()
        }
        break

      case 'TryStatement':
        try {
          walk(node.block)
        } catch (error) {
          enterBlock()
          var catchClause = node.handlers[0]
          if (catchClause) {
            blockContext[catchClause.param.name] = error
            walk(catchClause.body)
          }
          leaveBlock()
        } finally {
          if (node.finalizer) {
            walk(node.finalizer)
          }
        }
        break

      case 'Literal':
        return node.value

      case 'UnaryExpression':
        var val = walk(node.argument)
        switch(node.operator) {
          case '+': return +val
          case '-': return -val
          case '~': return ~val
          case '!': return !val
          case 'void': return void val
          case 'typeof': return typeof val
          default: return unsupportedExpression(node)
        }

      case 'ArrayExpression':
        var obj = blockContext['Array']()
        for (var i=0;i<node.elements.length;i++){
          obj.push(walk(node.elements[i]))
        }
        return obj

      case 'ObjectExpression':
        var obj = blockContext['Object']()
        for (var i = 0; i < node.properties.length; i++) {
          var prop = node.properties[i]
          var value = (prop.value === null) ? prop.value : walk(prop.value)
          obj[prop.key.value || prop.key.name] = value
        }
        return obj

      case 'NewExpression':
        var args = node.arguments.map(function(arg){
          return walk(arg)
        })
        var target = walk(node.callee)
        return primitives.applyNew(target, args)


      case 'BinaryExpression':
        var l = walk(node.left)
        var r = walk(node.right)

        switch(node.operator) {
          case '==':  return l === r
          case '===': return l === r
          case '!=':  return l != r
          case '!==': return l !== r
          case '+':   return l + r
          case '-':   return l - r
          case '*':   return l * r
          case '/':   return l / r
          case '%':   return l % r
          case '<':   return l < r
          case '<=':  return l <= r
          case '>':   return l > r
          case '>=':  return l >= r
          case '|':   return l | r
          case '&':   return l & r
          case '^':   return l ^ r
          case 'in':  return l in r
          case 'instanceof': return l instanceof r
          default: return unsupportedExpression(node)
        }

      case 'LogicalExpression':
        switch(node.operator) {
          case '&&':  return walk(node.left) && walk(node.right)
          case '||':  return walk(node.left) || walk(node.right)
          default: return unsupportedExpression(node)
        }

      case 'ThisExpression':
        return blockContext['this']

      case 'Identifier':
        if (node.name === 'undefined'){
          return undefined
        } else if (hasProperty(blockContext, node.name, primitives)){
          return finalValue(blockContext[node.name])
        } else {
          throw new ReferenceError(node.name + ' is not defined')
        }

      case 'CallExpression':

        var args = node.arguments.map(function(arg){
          return walk(arg)
        })
        var object = null
        var target = walk(node.callee)

        if (node.callee.type === 'MemberExpression'){
          object = walk(node.callee.object)
        }
        return target.apply(object, args)

      case 'MemberExpression':
        var obj = walk(node.object)
        if (node.computed){
          var prop = walk(node.property)
        } else {
          var prop = node.property.name
        }
        obj = primitives.getPropertyObject(obj, prop)
        return checkValue(obj[prop]);

      case 'ConditionalExpression':
        var val = walk(node.test)
        return val ? walk(node.consequent) : walk(node.alternate)

      case 'EmptyStatement':
        return

      default:
        return unsupportedExpression(node)
    }
  }

  // safely retrieve a value
  function checkValue(value){
    if (value === Function){
      value = safeFunction
    }
    return finalValue(value)
  }

  // block scope context control
  function enterBlock(){
    blockContext = Object.create(blockContext)
  }
  function leaveBlock(){
    blockContext = Object.getPrototypeOf(blockContext)
  }

  // set a value in the specified context if allowed
  function setValue(object, left, right, operator){
    var name = null

    if (left.type === 'Identifier'){
      name = left.name
      // handle parent context shadowing
      object = objectForKey(object, name, primitives)
    } else if (left.type === 'MemberExpression'){
      if (left.computed){
        name = walk(left.property)
      } else {
        name = left.property.name
      }
      object = walk(left.object)
    }

    // stop built in properties from being able to be changed
    if (canSetProperty(object, name, primitives)){
      switch(operator) {
        case undefined: return object[name] = walk(right)
        case '=':  return object[name] = walk(right)
        case '+=': return object[name] += walk(right)
        case '-=': return object[name] -= walk(right)
        case '++': return object[name]++
        case '--': return object[name]--
      }
    }

  }

}

// when an unsupported expression is encountered, throw an error
function unsupportedExpression(node){
  console.error(node)
  var err = new Error('Unsupported expression: ' + node.type)
  err.node = node
  throw err
}

// walk a provided object's prototypal hierarchy to retrieve an inherited object
function objectForKey(object, key, primitives){
  var proto = primitives.getPrototypeOf(object)
  if (!proto || hasOwnProperty(object, key)){
    return object
  } else {
    return objectForKey(proto, key, primitives)
  }
}

function hasProperty(object, key, primitives){
  var proto = primitives.getPrototypeOf(object)
  var hasOwn = hasOwnProperty(object, key)
  if (object[key] !== undefined){
    return true
  } else if (!proto || hasOwn){
    return hasOwn
  } else {
    return hasProperty(proto, key, primitives)
  }
}

function hasOwnProperty(object, key){
  return Object.prototype.hasOwnProperty.call(object, key)
}

function propertyIsEnumerable(object, key){
  return Object.prototype.propertyIsEnumerable.call(object, key)
}


// determine if we have write access to a property
function canSetProperty(object, property, primitives){
  if (property === '__proto__' || primitives.isPrimitive(object)){
    return false
  } else if (object != null){

    if (hasOwnProperty(object, property)){
      if (propertyIsEnumerable(object, property)){
        return true
      } else {
        return false
      }
    } else {
      return canSetProperty(primitives.getPrototypeOf(object), property, primitives)
    }

  } else {
    return true
  }
}

// generate a function with specified context
function getFunction(body, params, parentContext){
  return function(){
    var context = Object.create(parentContext),
      g = getGlobal()

    context['window'] = context['global'] = g

    if (this == g) {
      context['this'] = null
    } else {
      context['this'] = this
    }
    // normalize arguments array
    var args = Array.prototype.slice.call(arguments)
    context['arguments'] = arguments
    args.forEach(function(arg,idx){
      var param = params[idx]
      if (param){
        context[param] = arg
      }
    })
    var result = evaluateAst(body, context)

    if (result instanceof ReturnValue){
      return result.value
    }
  }
}

function finalValue(value){
  if (value instanceof ReturnValue){
    return value.value
  }
  return value
}

// get the name of an identifier
function getName(identifier){
  return identifier.name
}

// a ReturnValue struct for differentiating between expression result and return statement
function ReturnValue(type, value){
  this.type = type
  this.value = value
}

export default safeEval
