'use strict'

var logger = require('logger')('bluetooth-a2dp')
var inherits = require('util').inherits
var EventEmitter = require('events').EventEmitter
var floraFactory = require('@yoda/flora')
var FloraComp = require('@yoda/flora/comp')
var protocol = require('./protocol.json')
var AudioManager = require('@yoda/audio').AudioManager
var _ = require('@yoda/util')._
var stateFilters = require('./a2dp-statemap').stateFilters

/**
 * @typedef {object} PROFILE
 * @property {string} BLE - Bluetooth low energy profile.
 * @property {string} A2DP - Bluetooth advanced audio distribution profile.
 * @property {string} AVRCP - Bluetooth audio video remote control profile.
 */

/**
 * @typedef {object} A2DP_MODE
 * @property {string} SINK - A2dp sink mode.
 * @property {string} SOURCE - A2dp source mode.
 */

/**
 * @typedef {object} RADIO_STATE
 * @property {string} ON - Bluetooth radio state is ON.
 * @property {string} OFF - Bluetooth radio state is OFF.
 * @property {string} ON_FAILED - Turn on bluetooth radio failed.
 */

/**
 * @typedef {object} CONNECTION_STATE
 * @property {string} CONNECTED - Successfully connected to remote device.
 * @property {string} DISCONNECTED - Disconnected from remote deivce.
 * @property {string} CONNECT_FAILED - Failed to connect to remote device.
 * @property {string} AUTOCONNECT_FAILED - Auto connection to history paired device failed after turn on bluetooth.
 */

/**
 * @typedef {object} AUDIO_STATE
 * @property {string} PLAYING - Music stream is playing.
 * @property {string} PAUSED - Music stream is paused.
 * @property {string} STOPPED - Music stream is stopped.
 * @property {string} VOLUMN_CHANGED - Music stream's volumn is changed.
 * @property {string} MUSIC_INFO - Music information retrieved.
 */

/**
 * @typedef {object} DISCOVERY_STATE
 * @property {string} ON - Local device is discoverable.
 * @property {string} OFF - Local device is undiscoverable.
 * @property {string} FOUND_DEVICE - Found new around bluetooth devices.
 */

/**
 * When bluetooth's radio state is changed, such as on/off.
 * @event module:@yoda/bluetooth.BluetoothA2dp#radio_state_changed
 */

/**
 * When bluetooth's connection state is changed, such as connected/disconnected.
 * @event module:@yoda/bluetooth.BluetoothA2dp#connection_state_changed
 */

/**
 * When bluetooth's audio stream state is changed, such as playing/paused.
 * @event module:@yoda/bluetooth.BluetoothA2dp#audio_state_changed
 */

/**
 * When bluetooth's discovery state is changed, such as undiscoverable or device list changed.
 * @event module:@yoda/bluetooth.BluetoothA2dp#discovery_state_changed
 */

/**
 * Use `bluetooth.getAdapter(PROFILE.A2DP)` instead of this constructor.
 * @memberof module:@yoda/bluetooth
 * @constructor
 * @extends EventEmitter
 * @param {string} deviceName - The device name.
 */
function BluetoothA2dp (deviceName) {
  EventEmitter.call(this)

  this.lastMsg = {
    'a2dpstate': 'closed',
    'connect_state': 'invalid',
    'connect_address': null,
    'connect_name': null,
    'play_state': 'invalid',
    'broadcast_state': 'closed',
    'linknum': 0
  }
  this._end = false
  this.lastMode = protocol.A2DP_MODE.SINK // last used a2dp mode
  this.localName = deviceName // local bluetooth device name
  this.settingVolume = false

  this._flora = new FloraComp(null, {
    'uri': 'unix:/var/run/flora.sock',
    'bufsize': 40960,
    'reconnInterval': 10000
  })
  this._flora.handlers = {
    'bluetooth.a2dpsink.event': this._onSinkEvent.bind(this),
    'bluetooth.a2dpsource.event': this._onSourceEvent.bind(this)
  }
  this._flora.init()
}
inherits(BluetoothA2dp, EventEmitter)

/**
 * @private
 */
BluetoothA2dp.prototype.matchState = function (msg, filter) {
  if (filter === undefined || filter === null) {
    return true
  }
  return (filter.a2dpstate === msg.a2dpstate || filter.a2dpstate === undefined) &&
    (filter.connect_state === msg.connect_state || filter.connect_state === undefined) &&
    (filter.play_state === msg.play_state || filter.play_state === undefined || msg.play_state === undefined) &&
    (filter.broadcast_state === msg.broadcast_state || filter.broadcast_state === undefined) &&
    (filter.mode === this.lastMode || filter.mode === undefined)
}

/**
 * @private
 */
BluetoothA2dp.prototype.handleEvent = function (data, mode) {
  if (this._end) {
    logger.warn(`${mode} Already destroied!`)
    return
  }
  try {
    var msg = JSON.parse(data[0] + '')
    logger.debug(`on ${mode} event(action:${msg.action})`)

    if (msg.action === 'stateupdate') {
      var last = this.lastMsg
      logger.debug(`last: ${last.a2dpstate} ${last.connect_state} ${last.play_state} ${last.broadcast_state}`)
      logger.debug(`now:  ${msg.a2dpstate} ${msg.connect_state} ${msg.play_state} ${msg.broadcast_state}`)
      if (this.matchState(msg, this.lastMsg)) {
        logger.warn(`Received ${mode} same msg!`)
      }
      var lastMessage = Object.assign({}, this.lastMsg)
      this.lastMsg = Object.assign(this.lastMsg, msg)
      var stateHit = false
      stateFilters.forEach((filter) => {
        if (this.matchState(msg, filter.inflowMsg) && this.matchState(lastMessage, filter.lastInflowMsg)) {
          var event = filter.outflowEvent
          logger.debug(`Match ${event.type}.${event.state}`)
          var generator = filter.extraDataGenerator
          if (generator === undefined || generator === null || typeof generator !== 'function') {
            this.emit(event.type, mode, event.state)
          } else {
            var extraData = generator(msg)
            this.emit(event.type, mode, event.state, extraData)
          }
          stateHit = true
        }
      })
      if (!stateHit) {
        logger.warn(`Mismatch state, please check state-mapping!`)
      }
    } else if (msg.action === 'volumechange') {
      var vol = msg.value
      var sysVol = AudioManager.getVolume(AudioManager.STREAM_PLAYBACK)
      if (vol != null && vol !== sysVol) {
        this.settingVolume = true
        AudioManager.setVolume(AudioManager.STREAM_PLAYBACK, vol)
        logger.info(`Set volume ${vol} for bluetooth a2dp sink.`)
        this.emit('audio_state_changed', protocol.A2DP_MODE.SINK, protocol.AUDIO_STATE.VOLUMN_CHANGED, {volumn: vol})
      }
    } else if (msg.action === 'discovery') {
      var results = msg.results
      var nbr = results.deviceList != null ? results.deviceList.length : 0
      logger.debug(`Found ${nbr} devices, is_comp: ${results.is_completed}, currentDevice: ${results.currentDevice}`)
      if (nbr > 0) {
        results.deviceList.forEach((device) => {
          logger.debug(`  ${device.name} : ${device.address}`)
        })
      }
      this.emit('discovery_state_changed', protocol.A2DP_MODE.SOURCE, protocol.DISCOVERY_STATE.FOUND_DEVICE, results)
    } else if (msg.action === 'element_attrs') {
      var song = {
        title: msg.title,
        artist: msg.artist,
        album: msg.album
      }
      this.emit(`audio_state_changed`, protocol.A2DP_MODE.SINK, protocol.AUDIO_STATE.MUSIC_INFO, song)
    }
  } catch (err) {
    logger.error(`on ${mode} error(${JSON.stringify(err)})`)
  }
}

/**
 * @private
 */
BluetoothA2dp.prototype._onSinkEvent = function (data) {
  this.handleEvent(data, protocol.A2DP_MODE.SINK)
}

/**
 * @private
 */
BluetoothA2dp.prototype._onSourceEvent = function (data) {
  this.handleEvent(data, protocol.A2DP_MODE.SOURCE)
}

/**
 * @private
 * @returns {boolean} `true` if send success else `false`.
 */
BluetoothA2dp.prototype._send = function (mode, cmdstr, props) {
  var data = Object.assign({ command: cmdstr }, props)
  var msg = [ JSON.stringify(data) ]
  var name = (mode === protocol.A2DP_MODE.SINK ? 'bluetooth.a2dpsink.command' : 'bluetooth.a2dpsource.command')
  return this._flora.post(name, msg, floraFactory.MSGTYPE_INSTANT) === 0
}

/**
 * Turn on the bluetooth. It will starts `a2dp-sink` or `a2dp-source` according parameter `mode`.
 *
 * You can listen following changed states:
 * - `protocol.RADIO_STATE.ON` when bluetooth is opened successfully.
 * - `protocol.RADIO_STATE.ON_FAILED` when bluetooth cannot be opened.
 * - `protocol.CONNECTION_STATE.AUTOCONNECT_FAILED` when bluetooth opened but auto connect to history paired device failed.
 *
 * @param {A2DP_MODE} [mode] - Specify the bluetooth a2dp profile mode. Default will starts `A2DP_MODE.SINK`.
 * @param {object} [options] - The extra options.
 * @param {boolean} [options.autoplay=false] - Whether after autoconnected, music should be played automatically.
 * @returns {boolean} `true` if send command success else `false`.
 * @fires module:@yoda/bluetooth/BluetoothA2dp#radio_state_changed
 * @fires module:@yoda/bluetooth/BluetoothA2dp#connection_state_changed
 * @example
 * var bluetooth = require('@yoda/bluetooth')
 * var protocol = bluetooth.protocol
 * var a2dp = bluetooth.getAdapter(protocol.PROFILE.A2DP)
 * a2dp.on('radio_state_changed', function (mode, state, extra) {
 *   console.log(`bluetooth mode: ${mode}, state: ${state}`)
 * })
 * a2dp.open(protocol.A2DP_MODE.SINK, {autoplay: true})
 */
BluetoothA2dp.prototype.open = function (mode, options) {
  if (mode === undefined) {
    mode = protocol.A2DP_MODE.SINK
  }
  var autoplay = _.get(options, 'autoplay', false)
  logger.debug(`open(${this.lastMode}=>${mode}, autoplay:${autoplay})`)
  this.lastMode = mode
  var msg = {
    name: this.localName,
    unique: true
  }
  if (autoplay) {
    msg.subsequent = 'PLAY'
  }
  if (mode === protocol.A2DP_MODE.SINK) {
    // Bluetooth phone call is binded with bluetooth music.
    msg.sec_pro = 'HFP'
  }
  return this._send(mode, 'ON', msg)
}

/**
 * Turn off the bluetooth.
 *
 * You can listen following changed state events:
 * - `protocol.RADIO_STATE.OFF` when bluetooth is closed.
 *
 * @returns {boolean} `true` if send command success else `false`.
 * @fires module:@yoda/bluetooth/BluetoothA2dp#radio_state_changed
 */
BluetoothA2dp.prototype.close = function () {
  logger.debug(`close(${this.lastMode}, ${this.lastMsg.a2dpstate})`)
  if (this.lastMsg.a2dpstate === 'closed') {
    logger.warn('close() while last state is already closed.')
  }
  if (this.lastMode === protocol.A2DP_MODE.SINK) {
    // Bluetooth phone call is binded with bluetooth music.
    return this._send(this.lastMode, 'OFF', {sec_pro: 'HFP'})
  } else {
    return this._send(this.lastMode, 'OFF')
  }
}

/**
 * Connect bluetooth to remote device.
 *
 * You can listen following changed states:
 * - `protocol.CONNECTION_STATE.CONNECTED` when successfully connected to remote device.
 * - `protocol.CONNECTION_STATE.CONNECT_FAILED` when cannot connect to remote device.
 * @param {string} addr - Specify the remote bluetooth device's MAC address.
 * @param {string} name - Specify the remote bluetooth device's name.
 * @returns {boolean} `true` if send command success else `false`.
 * @fires module:@yoda/bluetooth/BluetoothA2dp#connection_state_changed
 */
BluetoothA2dp.prototype.connect = function (addr, name) {
  logger.debug(`connect(${this.lastMode}, ${name}:${addr})`)
  if (this.lastMode === protocol.A2DP_MODE.SOURCE) {
    var target = {'address': addr, 'name': name}
    if (this.lastMsg.a2dpstate !== 'opened') {
      logger.warn('connect() while last state is not opened.')
    }
    if (this.lastMsg.connect_state === 'connected' && this.lastMsg.connect_address === addr) {
      logger.warn('connect() to same already connected device?')
    }
    return this._send(this.lastMode, 'CONNECT', target)
  } else {
    logger.warn('connect() is not supported for SINK!')
    process.nextTick(() => {
      this.emit(protocol.STATE_CHANGED.CONNECTION, this.lastMode, protocol.CONNECTION_STATE.CONNECT_FAILED)
    })
    return false
  }
}

/**
 * Disconnect bluetooth from remote device.
 *
 * You can listen following changed state:
 * - `protocol.CONNECTION_STATE.DISCONNECTED` after disconnected from remote device.
 * @returns {boolean} `true` if send command success else `false`.
 * @fires module:@yoda/bluetooth/BluetoothA2dp#connection_state_changed
 */
BluetoothA2dp.prototype.disconnect = function () {
  logger.debug(`disconnect(${this.lastMode})`)
  if (this.lastMsg.connect_state !== 'connected') {
    logger.warn('disconnect() while last state is not connected.')
  }
  return this._send(this.lastMode, 'DISCONNECT')
}

/**
 * Mute a2dp-sink music stream.
 *
 *  No state changed after this command execution.
 * @returns {boolean} `true` if send command success else `false`.
 */
BluetoothA2dp.prototype.mute = function () {
  logger.debug(`mute(${this.lastMode})`)
  if (this.lastMode === protocol.A2DP_MODE.SINK) {
    return this._send(this.lastMode, 'MUTE')
  }
}

/**
 * Unmute a2dp-sink music stream.
 *
 * No state changed after this command execution.
 * @returns {boolean} `true` if send command success else `false`.
 */
BluetoothA2dp.prototype.unmute = function () {
  logger.debug(`unmute(${this.lastMode})`)
  if (this.lastMode === protocol.A2DP_MODE.SINK) {
    return this._send(this.lastMode, 'UNMUTE')
  }
}

/**
 * Sync volume to remote device.
 *
 * @param {string} [vol] - the volume number to be synced.
 * @returns {boolean} `true` if send command success else `false`.
 */
BluetoothA2dp.prototype.syncVol = function (vol) {
  if (this.settingVolume) {
    this.settingVolume = false
    logger.warn('Ignore sync while setting volume.')
    return true
  }
  logger.debug(`sync volume(${vol})`)
  return this._send(this.lastMode, 'VOLUME', { value: vol })
}

/**
 * Play a2dp-sink music stream.
 *
 * You can listen following changed state:
 * - `protocol.AUDIO_STATE.PLAYING` after music play started.
 * @returns {boolean} `true` if send command success else `false`.
 * @fires module:@yoda/bluetooth/BluetoothA2dp#audio_state_changed
 */
BluetoothA2dp.prototype.play = function () {
  logger.debug(`play(${this.lastMode}, play_state: ${this.lastMsg.play_state})`)
  if (this.lastMode === protocol.A2DP_MODE.SINK) {
    if (this.lastMsg.play_state === 'played') {
      logger.warn('play() while last state is already played.')
    }
    return this._send(this.lastMode, 'PLAY')
  }
}

/**
 * Pause a2dp-sink music stream.
 *
 * You can listen following changed state:
 * - `protocol.AUDIO_STATE.PAUSED` after music play paused.
 * @returns {boolean} `true` if send command success else `false`.
 * @fires module:@yoda/bluetooth/BluetoothA2dp#audio_state_changed
 */
BluetoothA2dp.prototype.pause = function () {
  logger.debug(`pause(${this.lastMode}, play_state: ${this.lastMsg.play_state})`)
  if (this.lastMode === protocol.A2DP_MODE.SINK) {
    if (this.lastMsg.play_state === 'stopped') {
      logger.warn('pause() while last state is already stopped.')
    }
    this.lastCmd = 'pause'
    return this._send(this.lastMode, 'PAUSE')
  }
}

/**
 * Stop a2dp-sink music stream.
 *
 * You can listen following changed state:
 * - `protocol.AUDIO_STATE.STOPPED` after music play stopped.
 * @returns {boolean} `true` if send command success else `false`.
 * @fires module:@yoda/bluetooth/BluetoothA2dp#audio_state_changed
 */
BluetoothA2dp.prototype.stop = function () {
  logger.debug(`stop(${this.lastMode}, play_state: ${this.lastMsg.play_state})`)
  if (this.lastMode === protocol.A2DP_MODE.SINK) {
    if (this.lastMsg.play_state === 'stopped') {
      logger.warn('stop() while last state is already stopped.')
    }
    this.lastCmd = 'stop'
    return this._send(this.lastMode, 'STOP')
  }
}

/**
 * Play a2dp-sink previous song.
 *
 * No state changed after this command execution.
 * @returns {boolean} `true` if send command success else `false`.
 */
BluetoothA2dp.prototype.prev = function () {
  logger.debug(`prev(${this.lastMode})`)
  if (this.lastMode === protocol.A2DP_MODE.SINK) {
    return this._send(this.lastMode, 'PREV')
  }
}

/**
 * Play a2dp-sink next song.
 *
 * No state changed after this command execution.
 * @returns {boolean} `true` if send command success else `false`.
 */
BluetoothA2dp.prototype.next = function () {
  logger.debug(`next(${this.lastMode})`)
  if (this.lastMode === protocol.A2DP_MODE.SINK) {
    return this._send(this.lastMode, 'NEXT')
  }
}

/**
 * Query playing song's information such as album, title, artist, etc.
 *
 * You can listen following changed state:
 * - `protocol.AUDIO_STATE.MUSIC_INFO`.
 * @returns {boolean} `true` if send command success else `false`.
 * @fires module:@yoda/bluetooth/BluetoothA2dp#audio_state_changed
 */
BluetoothA2dp.prototype.query = function () {
  logger.debug(`query song info`)
  if (this.lastMode === protocol.A2DP_MODE.SINK) {
    return this._send(this.lastMode, 'GETSONG_ATTRS')
  }
}

/**
 * Fast forward playing song.
 *
 * @return {boolean} `true` if send command success else `false`.
 * @fires module:@yoda/bluetooth/BluetoothA2dp#audio_state_changed
 */
BluetoothA2dp.prototype.ffward = function () {
  logger.debug('fast forward')
  if (this.lastMode === protocol.A2DP_MODE.SINK) {
    return this._send(this.lastMode, 'SEND_KEY', { key: 0x49 })
  }
}

/**
 * Rewind playing song.
 *
 * @return {boolean} `true` if send command success else `false`.
 * @fires module:@yoda/bluetooth/BluetoothA2dp#audio_state_changed
 */
BluetoothA2dp.prototype.rewind = function () {
  logger.debug('rewind')
  if (this.lastMode === protocol.A2DP_MODE.SINK) {
    return this._send(this.lastMode, 'SEND_KEY', { key: 0x48 })
  }
}

/**
 * Set local device discoverable.
 *
 * You can listen following changed state:
 * - `protocol.DISCOVER_STATE.ON` after set succeeded.
 * @returns {boolean} `true` if send command success else `false`.
 * @fires module:@yoda/bluetooth/BluetoothA2dp#discovery_state_changed
 * @private
 */
BluetoothA2dp.prototype.setDiscoverable = function () {
  logger.debug('Todo: not implemenet yet.')
  return true
}

/**
 * Set local device undiscoverable.
 *
 * You can listen following changed state:
 * - `protocol.DISCOVER_STATE.OFF` after set succeeded.
 * @returns {boolean} `true` if send command success else `false`.
 * @fires module:@yoda/bluetooth/BluetoothA2dp#discovery_state_changed
 * @private
 */
BluetoothA2dp.prototype.setUndiscoverable = function () {
  logger.debug('Todo: not implemenet yet.')
  return true
}

/**
 * Discovery around bluetooth devices.
 *
 * You can listen following changed state:
 * - `protocol.DISCOVER_STATE.FOUND_DEVICE` while some bluetooth devices have been found.
 * @returns {boolean} `true` if send command success else `false`.
 * @fires module:@yoda/bluetooth/BluetoothA2dp#discovery_state_changed
 */
BluetoothA2dp.prototype.discovery = function () {
  logger.debug(`discovery(${this.lastMode}, cur state: ${this.lastMsg.a2dpstate})`)
  if (this.lastMode === protocol.A2DP_MODE.SOURCE) {
    if (this.lastMsg.a2dpstate !== 'opened') {
      logger.warn('discovery() while last state is not opened.')
    }
    return this._send(this.lastMode, 'DISCOVERY')
  }
}

/**
 * Get current running A2DP profile mode.
 * @returns {A2DP_MODE} - The current running A2DP profile mode.
 */
BluetoothA2dp.prototype.getMode = function () {
  return this.lastMode
}

/**
 * Get a2dp radio state.
 * @returns {RADIO_STATE} - The current radio state.
 */
BluetoothA2dp.prototype.getRadioState = function () {
  if (this.lastMsg.a2dpstate === 'opened') {
    return protocol.RADIO_STATE.ON
  } else {
    return protocol.RADIO_STATE.OFF
  }
}

/**
 * Get a2dp connection state.
 * @return {CONNECTION_STATE} - The current connection state.
 */
BluetoothA2dp.prototype.getConnectionState = function () {
  if (this.lastMsg.connect_state === 'connected') {
    return protocol.CONNECTION_STATE.CONNECTED
  } else {
    return protocol.CONNECTION_STATE.DISCONNECTED
  }
}

/**
 * Get a2dp-sink audio state.
 * @return {AUDIO_STATE} - The current audio state.
 */
BluetoothA2dp.prototype.getAudioState = function () {
  if (this.lastMsg.play_state === 'played') {
    return protocol.AUDIO_STATE.PLAYING
  } else {
    return protocol.AUDIO_STATE.STOPPED
  }
}

/**
 * Get a2dp discovery state.
 * @return {DISCOVERY_STATE} - The currenct discovery state.
 */
BluetoothA2dp.prototype.getDiscoveryState = function () {
  if (this.lastMsg.broadcast_state === 'opened') {
    return protocol.DISCOVERY_STATE.ON
  } else {
    return protocol.DISCOVERY_STATE.OFF
  }
}

/**
 * Get if bluetooth is opened.
 * @returns {boolean} - `true` if bluetooth is opened else `false`.
 */
BluetoothA2dp.prototype.isOpened = function () {
  return this.lastMsg.a2dpstate === 'opened'
}

/**
 * Get if this device is connected with remote device.
 * @returns {boolean} - `true` if blueooth is connected with remote device else `false`.
 */
BluetoothA2dp.prototype.isConnected = function () {
  return this.lastMsg.connect_state === 'connected'
}

/**
 * @typedef {object} BluetoothDevice
 * @property {string} name - The device's name.
 * @property {string} address - The device's MAC address.
 */

/**
 * Get connected bluetooth device.
 * @returns {BluetoothDevice|null} - Current connected bluetooth device object or `null` if no connected device.
 */
BluetoothA2dp.prototype.getConnectedDevice = function () {
  if (!this.isConnected()) {
    return null
  } else {
    return {
      address: this.lastMsg.connect_address,
      name: this.lastMsg.connect_name
    }
  }
}

/**
 * Get if a2dp-sink music is playing.
 * @returns {boolean} - `true` if bluetooth music is playing else `false`.
 */
BluetoothA2dp.prototype.isPlaying = function () {
  return this.lastMsg.play_state === 'played'
}

/**
 * Get if this deivce is under discoverable.
 * @returns {boolean} - `true` if local device is under discoverable else `false`.
 */
BluetoothA2dp.prototype.isDiscoverable = function () {
  return this.lastMsg.broadcast_state === 'opened'
}

/**
 * Destroy bluetooth profile adapter, thus means bluetooth will always be turned `OFF` automatically.
 */
BluetoothA2dp.prototype.destroy = function () {
  logger.debug(`destroy()`)
  this.removeAllListeners()
  this._flora.deinit()
  this._end = true
}

exports.BluetoothA2dp = BluetoothA2dp
