###
# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
###

randomIndex = (count) ->
  Math.floor Math.random() * count

shuffleArray = (array) ->
  shuffled = ( a for a in array )
  for i in [0...shuffled.length]
    j = i + Math.floor(Math.random() * (shuffled.length - i))
    a = shuffled[i]
    b = shuffled[j]
    shuffled[i] = b
    shuffled[j] = a
  return shuffled

randomArrayItem = (array) ->
  array[randomIndex(array.length)]

diceRoll = (sides) ->
  # produce a number between 1 and sides, inclusive
  sides = sides ? 6
  1 + Math.floor( Math.random() * sides )

diceCheck = (number, sides) ->
  diceRoll(sides) <= number

escapeSpeech = (line) ->
  return "" unless line?
  return "" + line

deepClone = (thing) ->
  JSON.parse( JSON.stringify(thing) )

isActuallyANumber = (data) -> not isNaN(parseInt(data))

pickSayString = (context, key, count) ->
  sayData = context.db.read('__sayHistory') ? []
  history = sayData[key] ? []
  value = 0
  switch
    when count == 2
      # with two, we can only toggle anyway
      if history[0]?
        value = 1 - history[0]
      else
        value = randomIndex(2)
      history[0] = value % 2

    when count < 5
      # until 4, the pattern below is a little
      # over constrained, producing a repeating
      # set rather than a random sequence,
      # so we only guarantee
      # no adjacent repetition instead
      value = randomIndex(count)
      if value == history[0]
        value = ( value + 1 ) % count
      history[0] = value % 5

    else
      # otherwise, guarantee we'll see at least
      # half the remaining options before repeating
      # one, up to a capped history of 8, beyond which
      # it's likely too difficult to detect repetition.
      value = randomIndex(count)
      for i in [0...count]
        break unless value in history
        value = ( value + 1 ) % count
      history.unshift value
      cap = Math.min 8, count / 2
      history = history[0...cap]

  sayData[key] = history
  context.db.write '__sayHistory', sayData
  return value % count

pickSayFragment = (context, key, options) ->
  index = pickSayString context, key, options.length
  return options[index]


exports.DataTablePrototype =
  pickRandomIndex: ->
    return randomIndex(@length)

  find: (key, value) ->
    idx = @keys[key]
    return null unless idx?
    for row in [0...length]
      if @[row][idx] == value
        return row
    return null


exports.Logging =
  log: ->
    console.log.apply( null, arguments )
  error: ->
    console.error.apply( null, arguments )


minutesBetween = (before, now) ->
  return 999999 unless before? and now?
  Math.floor( Math.abs(now - before) / (60 * 1000) )

hoursBetween = (before, now) ->
  return 999999 unless before? and now?
  Math.floor( Math.abs(now - before) / (60 * 60 * 1000) )

daysBetween = (before, now) ->
  return 999999 unless before? and now?
  now = (new Date(now)).setHours(0, 0, 0, 0)
  before = (new Date(before)).setHours(0, 0, 0, 0)
  Math.floor( Math.abs(now - before) / ( 24 * 60 * 60 * 1000 ) )


Math.clamp = (min, max, x) ->
  Math.min( Math.max( min, x ), max )

rgbFromHex = (hex) ->
  return [0,0,0] unless hex?.length?
  hex = hex[2..] if hex.indexOf('0x') == 0
  hex = hex[1..] if hex.indexOf('#') == 0
  return switch hex.length
    when 3
      read = (v) ->
        v = parseInt(v, 16)
        return v + 16 * v
      [ read(hex[0]), read(hex[1]), read(hex[2]) ]
    when 6
      [ parseInt(hex[0..1],16), parseInt(hex[2..3],16), parseInt(hex[4..5],16) ]
    else
      [0,0,0]

hexFromRGB = (rgb) ->
  r = Math.clamp( 0, 255, Math.floor(rgb[0]) ).toString(16)
  g = Math.clamp( 0, 255, Math.floor(rgb[1]) ).toString(16)
  b = Math.clamp( 0, 255, Math.floor(rgb[2]) ).toString(16)
  r = "0" + r if r.length < 2
  g = "0" + g if g.length < 2
  b = "0" + b if b.length < 2
  r + g + b

rgbFromHSL = (hsl) ->
  h = ( hsl[0] % 360 + 360 ) % 360
  s = Math.clamp( 0.0, 1.0, hsl[1] )
  l = Math.clamp( 0.0, 1.0, hsl[2] )
  h /= 60.0
  c = (1.0 - Math.abs(2.0 * l - 1.0)) * s
  x = c * (1.0 - Math.abs(h % 2.0 - 1.0))
  m = l - 0.5 * c
  c += m
  x += m
  m = Math.floor( m * 255 )
  c = Math.floor( c * 255 )
  x = Math.floor( x * 255 )
  switch Math.floor(h)
    when 0 then return [c,x,m]
    when 1 then return [x,c,m]
    when 2 then return [m,c,x]
    when 3 then return [m,x,c]
    when 4 then return [x,m,c]
    else        return [c,m,x]

brightenColor = (c, percent) ->
  isHex = false
  unless Array.isArray(c)
    c = rgbFromHex(c)
    isHex = true
  c = interpolateRGB c, [255,255,255], percent / 100.0
  if isHex
    return hexFromRGB(c)
  return c

interpolateRGB = (c1, c2, l) ->
  [r, g, b] = c1
  r += (c2[0] - r) * l
  g += (c2[1] - g) * l
  b += (c2[2] - b) * l
  [r.toFixed(0), g.toFixed(0), b.toFixed(0)]


reportValueMetric = ( metricType, value, unit ) ->
  params =
    MetricData: []
    Namespace: 'Litexa'

  params.MetricData.push {
    MetricName: metricType
    Dimensions: [
      {
        Name: 'project'
        Value: litexa.projectName
      }
    ],
    StorageResolution: 60
    Timestamp: new Date().toISOString()
    Unit: unit ? 'None'
    Value: value ? 1
  }
  #console.log "reporting metric #{JSON.stringify(params)}"

  return unless cloudWatch?
  cloudWatch.putMetricData params, (err, data) ->
    if err?
      console.error( "Cloudwatch metrics write fail #{err}" )

litexa.extensions =
  postProcessors: []
  extendedEvents: {}
  load: (location, name) ->
    # during testing, this might already be in the shared context, skip it if so
    if name of litexa.extensions
      #console.log ("skipping extension load, already loaded")
      return

    testing = if litexa.localTesting then "(test mode)" else ""
    #console.log "loading extension #{location}/#{name} #{testing}"
    fullPath = "#{litexa.modulesRoot}/#{location}/#{name}/litexa.extension"
    lib = litexa.extensions[name] = require fullPath

    if lib.loadPostProcessor?
      handler = lib.loadPostProcessor(litexa.localTesting)
      if handler?
        #console.log "installing post processor for extension #{name}"
        handler.extensionName = name
        litexa.extensions.postProcessors.push handler

    if lib.events?
      for k, v of lib.events(false)
        #console.log "registering extended event #{k}"
        litexa.extensions.extendedEvents[k] = v


  finishedLoading: ->
    # sort the postProcessors by their actions
    processors = litexa.extensions.postProcessors
    count = processors.length

    # identify dependencies
    for a in processors
      a.dependencies = []
      continue unless a.consumesTags?
      for tag in a.consumesTags
        for b in processors when b != a
          continue unless b.producesTags?
          if tag in b.producesTags
            a.dependencies.push b

    ready = ( a for a in processors when a.dependencies.length == 0 )
    processors = ( p for p in processors when p.dependencies.length > 0 )
    sorted = []
    for guard in [0...count]
      break if ready.length == 0
      node = ready.pop()
      for p in processors
        p.dependencies = ( pp for pp in p.dependencies when pp != node )
        if p.dependencies.length == 0
          ready.push p
      processors = ( p for p in processors when p.dependencies.length > 0 )
      sorted.push node

    unless sorted.length == count
      throw new Error "Failed to sort postprocessors by dependency"

    litexa.extensions.postProcessors = sorted


class DBTypeWrapper
  constructor: (@db, @language) ->
    @cache = {}

  read: (name) ->
    if name of @cache
      return @cache[name]
    dbType = __languages[@language].dbTypes[name]
    value = @db.read name

    if dbType?.prototype?
      # if this is a typed variable, and it appears
      # the type is a constructible, e.g. a Class
      if value?
        # patch the prototype if it exists
        Object.setPrototypeOf value, dbType.prototype
      else
        # or construct a new instance
        value = new dbType
        @db.write name, value

    else if dbType?.Prepare?
      # otherwise if it's typed and it provides a
      # wrapping Prepare function
      unless value?
        if dbType.Initialize?
          # optionally invoke an initialize
          value = dbType.Initialize()
        else
          # otherwise assume we start from an
          # empty object
          value = {}
        @db.write name, value
      # wrap the cached object, whatever it is
      # the function wants to return. Note it's
      # still the input value object that gets saved
      # to the database either way!
      value = dbType.Prepare(value)

    @cache[name] = value
    return value

  write: (name, value) ->
    # clear out the cache on any writes
    delete @cache[name]

    dbType = __languages[@language].dbTypes[name]
    if dbType?
      # for typed objects, we can only replace with
      # another object, OR clear out the object and
      # let initialization happen again on the next
      # read, whenever that happens
      if not value?
        @db.write name, null
      else if typeof(value) == 'object'
        @db.write name, value
      else
        throw new Error "@#{name} is a typed variable, you can only assign an object or null to it."
    else
      @db.write name, value

  finalize: (cb) ->
    @db.finalize cb

# Monetization
inSkillProductBought = (stateContext, referenceName) ->
  isp = await getProductByReferenceName(stateContext, referenceName)
  return (isp?.entitled == 'ENTITLED')

getProductByReferenceName = (stateContext, referenceName) ->
  if stateContext.monetization.fetchEntitlements
    await fetchEntitlements stateContext

  for p in stateContext.monetization.inSkillProducts
    if p.referenceName == referenceName
      return p
  return null

getProductByProductId = (stateContext, productId) ->
  if stateContext.monetization.fetchEntitlements
    await fetchEntitlements stateContext

  for p in stateContext.monetization.inSkillProducts
    if p.productId == productId
      return p
  return null

buildBuyInSkillProductDirective = (stateContext, referenceName) ->
  isp = await getProductByReferenceName stateContext, referenceName
  unless isp?
    console.log "buildBuyInSkillProductDirective(): in-skill product \"#{referenceName}\" not
      found."
    return

  stateContext.directives.push {
      "type": "Connections.SendRequest"
      "name": "Buy"
      "payload": {
        "InSkillProduct": {
          "productId": isp.productId
        }
      }
      "token": "bearer " + stateContext.event.context.System.apiAccessToken
    }

  stateContext.shouldEndSession = true

fetchEntitlements = (stateContext, ignoreCache = false) ->
  if !stateContext.monetization.fetchEntitlements and !ignoreCache
    return Promise.resolve()

  new Promise (resolve, reject) ->
    try
      https = require('https')
    catch
      console.log "skipping fetchEntitlements, no https present"
      reject()

    unless stateContext.event.context.System.apiEndpoint
      # If there's no API endpoint this is an offline test.
      resolve()

    # endpoint is region-specific:
    # e.g. https://api.amazonalexa.com vs. https://api.eu.amazonalexa.com
    apiEndpoint = stateContext.event.context.System.apiEndpoint
    apiEndpoint = apiEndpoint.replace("https://", "")
    apiPath = "/v1/users/~current/skills/~current/inSkillProducts"
    token = "bearer " + stateContext.event.context.System.apiAccessToken

    options =
      host: apiEndpoint
      path: apiPath
      method: 'GET'
      headers:
        "Content-Type": 'application/json'
        "Accept-Language": stateContext.request.locale
        "Authorization": token

    req = https.get options, (res) =>
      res.setEncoding("utf8")

      if res.statusCode != 200
        reject()

      returnData = ""
      res.on 'data', (chunk) =>
        returnData += chunk

      res.on 'end', () =>
        console.log("fetchEntitlements() returned: #{returnData}")
        stateContext.monetization.inSkillProducts = JSON.parse(returnData).inSkillProducts ? []
        stateContext.monetization.fetchEntitlements = false
        stateContext.db.write "__monetization", stateContext.monetization
        resolve()

    req.on 'error', (e) ->
      console.log "Error while querying inSkillProducts: #{e}"
      reject(e)

getReferenceNameByProductId = (stateContext, productId) ->
  for p in stateContext.monetization.inSkillProducts
    if p.productId == productId
      return p.referenceName
  return null

buildCancelInSkillProductDirective = (stateContext, referenceName) =>
  isp = await getProductByReferenceName(stateContext, referenceName)
  unless isp?
    console.log "buildCancelInSkillProductDirective(): in-skill product \"#{referenceName}\" not
      found."
    return

  stateContext.directives.push {
      "type": "Connections.SendRequest"
      "name": "Cancel"
      "payload": {
        "InSkillProduct": {
          "productId": isp.productId
        }
      }
      "token": "bearer " + stateContext.event.context.System.apiAccessToken
    }

  stateContext.shouldEndSession = true

buildUpsellInSkillProductDirective = (stateContext, referenceName, upsellMessage = '') =>
  isp = await getProductByReferenceName(stateContext, referenceName)
  unless isp?
    console.log "buildUpsellInSkillProductDirective(): in-skill product \"#{referenceName}\" not
      found."
    return

  stateContext.directives.push {
      "type": "Connections.SendRequest"
      "name": "Upsell"
      "payload": {
        "InSkillProduct": {
          "productId": isp.productId
        }
        "upsellMessage": upsellMessage
      }
      "token": "bearer " + stateContext.event.context.System.apiAccessToken
    }

  stateContext.shouldEndSession = true
