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

lib = module.exports.lib = {}

{ Function } = require('./function.coffee').lib
{ Intent, FilteredIntent } = require('./intent.coffee').lib
{ ParserError } = require("./errors.coffee").lib

class lib.Transition
  constructor: (@name, @stop) ->

  toLambda: (output, indent, options) ->
    # this overrides any instruction to stop and return a response
    output.push "#{indent}delete context.shouldEndSession;"
    output.push "#{indent}delete context.shouldDropSession;"

    # queue the next state
    output.push "#{indent}context.nextState = '#{@name}';"
    if @stop
      output.push "#{indent}context.handoffState = '#{@name}';"
      output.push "#{indent}context.handoffIntent = true;"
      output.push "#{indent}return;"

  validateStateTransitions: (allStateNames) ->
    unless @name in allStateNames
      throw new ParserError @location, "Transition to non existant state: #{@name}"

class lib.HandoffIntent
  constructor: (@name) ->

  toLambda: (output, indent, options) ->
    output.push "#{indent}context.handoffState = '#{@name}';"
    output.push "#{indent}context.handoffIntent = true;"

  validateStateTransitions: (allStateNames) ->
    unless @name in allStateNames
      throw new ParserError @location, "Handoff to non existant state: #{@name}"

class lib.SetSkillEnd
  constructor: ->

  toLambda: (output, indent, options) ->
    # cancel any pending state transitions or handoffs
    output.push "#{indent}context.nextState = null;"
    output.push "#{indent}context.handoffState = null;"
    output.push "#{indent}context.handoffIntent = null;"
    output.push "#{indent}delete context.shouldDropSession;"
    # flag that we're exiting with this response
    output.push "#{indent}context.shouldEndSession = true;"

class lib.SetSkillListen
  constructor: (@kinds) ->

  toLambda: (output, indent, options) ->
    # cancel any pending state transitions or handoffs
    output.push "#{indent}context.nextState = null;"
    output.push "#{indent}context.handoffState = null;"
    output.push "#{indent}context.handoffIntent = null;"

    # we don't want a session end
    output.push "#{indent}context.shouldEndSession = false;"
    unless 'microphone' in @kinds
      # but we're not listening for the microphone
      output.push "#{indent}context.shouldDropSession = true;"


class lib.LogMessage
  constructor: (@contents) ->

  toLambda: (output, indent, options) ->
    output.push "#{indent}exports.Logging.log(JSON.stringify(#{@contents.toLambda(options)}));"


class lib.State
  constructor: (@name) ->
    @intents = {}
    @languages = {}
    @parsePhase = 'start'
    @pushOrGetIntent null, '--default--', null
    @parsePhase = 'start'
    @locations = { default: null }

  isState: true

  prepareForLanguage: (location) ->
    return unless location?.language
    return if location.language == 'default'
    unless location.language of @languages
      @languages[location.language] = {}

  resetParsePhase: ->
    # used by the default constructors, pre parser
    @parsePhase = 'start'

  collectDefinedSlotTypes: (context, customSlotTypes) ->
    workingIntents = @collectIntentsForLanguage(context.language)
    for name, intent of workingIntents
      intent.collectDefinedSlotTypes context, customSlotTypes

  validateSlotTypes: (context, customSlotTypes) ->
    workingIntents = @collectIntentsForLanguage(context.language)
    for name, intent of workingIntents
      intent.validateSlotTypes customSlotTypes

  validateTransitions: (allStateNames, language) ->
    @startFunction?.validateStateTransitions(allStateNames, language)
    for name, intent of @intents
      intent.validateStateTransitions allStateNames, language
    for name, intent of @languages[language]
      intent.validateStateTransitions allStateNames, language

  hasIntent: (name, language) ->
    workingIntents = @collectIntentsForLanguage language
    for intentName, intent of workingIntents
      return true if name == intentName

    return false

  reportIntents: (language, output) ->
    workingIntents = @collectIntentsForLanguage language
    for name, intent of workingIntents
      continue if name == '--default--'
      report = intent.report()
      output[report] = true

  getIntentInLanguage: (language, intentName) ->
    if language == 'default'
      return @intents[intentName]
    return @languages[language]?[intentName]

  pushOrGetIntent: (location, utterance, intentInfo) ->
    switch @parsePhase
      when 'start'
        @parsePhase = 'intents'
      when 'intents'
        # fine
      else
        throw new ParserError location, "cannot add a new intent handler to the state `#{@name}` at
          this location. Have you already added state exit code before here? Check your indentation."
    try
      key = Intent.utteranceToName(location, utterance)
    catch err
      throw new ParserError location, "Cannot create intent name from `#{utterance}`: #{err}"
    language = location?.language ? 'default'
    collection = @intents

    if language != 'default'
      @languages[language] = @languages[language] ? {}
      collection = @languages[language]
    unless key of collection
      if intentInfo?.class?
        collection[key] = new intentInfo.class({ location, utterance })
      else
        collection[key] = new Intent({ location, utterance })
    else if !collection[key].defaultedResetOnGet and key != '--default--'
      # only allow repeat intents if they are events that can be filtered
      if collection[key] not instanceof FilteredIntent
        throw new ParserError location, "Not allowed to redefine intent `#{key}` in state `#{@name}`"

    intent = collection[key]

    if intent.defaultedResetOnGet
      intent.resetCode()
      intent.defaultedResetOnGet = undefined

    intent.allLocations.push location
    return intent

  pushCode: (line) ->
    switch @parsePhase
      when 'start'
        @startFunction = @startFunction ? new Function
        @startFunction.pushLine(line)
      when 'end', 'intents'
        @endFunction = @endFunction ? new Function
        @endFunction.pushLine(line)
        @parsePhase = 'end'
      else
        throw new ParserError line.location, "cannot add code to the state `#{@name}` here, you've already begun defining intents"

  collectIntentsForLanguage: (language) ->
    workingIntents = {}
    # for a given state, you will get the intents in that locale's
    # version of that state only (intents are not inherited from the parent state)
    if language of @languages
      for name, intent of @languages[language]
        workingIntents[name] = intent
      if @name == 'global'
        unless '--default--' of workingIntents
          workingIntents['--default--'] = @intents['--default--']
    else if @intents?
      for name, intent of @intents
        workingIntents[name] = intent
    return workingIntents

  toLambda: (output, options) ->
    workingIntents = @collectIntentsForLanguage(options.language)

    options.scopeManager = new (require('./variableScope').VariableScopeManager)(@locations[options.language], @name)
    options.scopeManager.currentScope.referenceTester = options.referenceTester

    enterFunc = []
    @startFunction?.toLambda(enterFunc, "", options)

    exitFunc = []
    if @endFunction?
      @endFunction.toLambda(exitFunc, "", options)

    childIntentsEncountered = []
    intentsFunc = []
    intentsFunc.push "switch( context.intent ) {"
    for name, intent of workingIntents
      options.scopeManager.pushScope intent.location, "intent:#{name}"
      if name == '--default--'
        intentsFunc.push "  default: {"

        if @name == 'global'
          intentsFunc.push "    if (!runOtherwise) { return false; }"

        if @name != 'global'
          intentsFunc.push "    if ( await processIntents.global(context, #{not intent.hasContent}) ) { return true; }"
          if intent.hasContent
            intent.toLambda(intentsFunc, options)
        else if intent.hasContent
            intent.toLambda(intentsFunc, options)
        else
          if options.strictMode
            intentsFunc.push "    throw new Error('unhandled intent ' + context.intent + ' in state ' + context.handoffState);"
          else
            intentsFunc.push "    console.error('unhandled intent ' + context.intent + ' in state ' + context.handoffState);"
      else
        # Child intents are registered to the state as handlers, but it is parent handlers that perform the logic
        # of adding them to the same switch case. Therefore, keep track of the ones already added to transformed code and
        # ignore them if they are encountered again.
        if childIntentsEncountered.includes intent.name
          options.scopeManager.popScope()
          continue

        for intentName in intent.childIntents
          intentsFunc.push "  case '#{intentName}':"
          childIntentsEncountered.push intentName

        intentsFunc.push "  case '#{intent.name}': {"

        if intent.code?
          for line in intent.code.split('\n')
            intentsFunc.push "    " + line
        else
          intent.toLambda(intentsFunc, options)
      intentsFunc.push "    break;\n    }"
      options.scopeManager.popScope()
    intentsFunc.push "}"

    unless options.scopeManager.depth() == 1
      throw new ParserError @locations[options.language], "scope imbalance: returned to state but
        scope has #{options.scopeManager.depth()} depth"

    # if we have local variables in the root scope that are accessed
    # after the enter function, then we need to persist those to the
    # database in a special state scope
    rootScope = options.scopeManager.currentScope
    if rootScope.hasDescendantAccess()
      names = []

      # collect names
      for k, v of rootScope.variables when v.accesedByDescendant
        names.push k

      # unpack into local variables at the start of handlers, except
      # for the entry handler where they're initialized
      unpacker = "let {#{names.join ', '}} = context.db.read('__stateLocals') || {};"
      intentsFunc.unshift unpacker
      exitFunc.unshift unpacker

      # pack into database object and the end of handlers, except
      # for the exit state, where they're forgotten
      packer = "context.db.write('__stateLocals', {#{names.join ', '}} );"
      enterFunc.push packer
      intentsFunc.push packer


    output.push "enterState.#{@name} = async function(context) {"
    output.push "  " + e for e in enterFunc
    output.push "};"

    intentsFunc.push "return true;"
    output.push "processIntents.#{@name} = async function(context, runOtherwise) {"
    output.push "  " + e for e in intentsFunc
    output.push "};"

    output.push "exitState.#{@name} = async function(context) {"
    output.push "  " + e for e in exitFunc
    output.push "};"

    output.push ""

  hasStatementsOfType: (types) ->
    if @startFunction?
      return true if @startFunction.hasStatementsOfType(types)
    if @intents?
      for name, intent of @intents
        return true if intent.hasStatementsOfType(types)
    return false

  collectRequiredAPIs: (apis) ->
    @startFunction?.collectRequiredAPIs(apis)
    @endFunction?.collectRequiredAPIs(apis)
    if @intents?
      for name, intent of @intents
        intent.collectRequiredAPIs apis

  toUtterances: (output) ->
    workingIntents = @collectIntentsForLanguage(output.language)
    for name, intent of workingIntents
      continue if intent.referenceIntent?
      intent.toUtterances(output)

  toModelV2: (output, context, extendedEventNames) ->
    workingIntents = @collectIntentsForLanguage(context.language)
    for name, intent of workingIntents
      continue if name == '--default--'
      continue if intent.referenceIntent?
      unless intent.hasUtterances
        unless (name in extendedEventNames) or name.includes('.')
          console.warn "`#{name}` does not have utterances; not adding to language model."
        continue
      try
        model = intent.toModelV2(context)
      catch err
        if err.location
          throw err # ParserErrors have location properties; propagate the error
        else 
          throw new Error "failed to write language model for state `#{@name}`: #{err}"

      continue unless model?

      if model.name of context.intents
        console.error "duplicate `#{model.name}` intent found while writing model"
      else
        context.intents[model.name] = model
        output.languageModel.intents.push model

  toLocalization: (localization) ->
    @startFunction?.toLocalization(localization)

    for name, intent of @intents
      if !localization.intents[name]? and name != '--default--' # 'otherwise' handler -> no utterances
        # if this is a new intent, add it to the localization map
        localization.intents[name] = { default: [] }

      # add utterances mapped to the intent, and speech lines in the intent handler
      intent.toLocalization(localization)
