path          = require 'path'
through       = require 'through2'
_             = require 'underscore'
PluginError   = require 'plugin-error'

PLUGIN_NAME = 'bitwig-rewrite-meta'
# ucs2 encoding endian
IS_UCS2_LE  = (Buffer.from 'a', 'ucs2')[0]

# bwpreset chunk1
# ----------------------------------------------
# Polysynth Acido.bwpreset (1.1 BETA 1)
#
#  chunk identifier: 00000561
#  ---------------------------------
#  item identifier:  00001423 (unknown)
#  value type:       05 (byte)
#  value:            00
#  ---------------------------------
#  item identifier:  0000150a (unknown)
#  value type:       05 (byte)
#  value:            00
#  ---------------------------------
#  item identifier:  00001421 (unknown)
#  value type:       09 (32bit)
#  value:            00000040
#  ---------------------------------
#  item identifier:  000002b9 (unknown)
#  value type:       08 (string)
#  string size:      00000000
#  ---------------------------------
#  item identifier   000012de (preset_name)
#  value type:       08 (string)
#  string size:      00000005
#  value:            416369646f ("Acido")
#  ---------------------------------
#  item identifier:  0000 009a (device_name)
#  value type:       08 (string)
#  string size:      00000009
#  value:            506f6c7973796e7468 ("Polysynth")
#  ---------------------------------
#  item identifer:   0000009b (device_creator)
#  value type:       08 (string)
#  string size:      00000006
#  value:            426974776967 ("Bitwig")
#  ---------------------------------
#  item identifier:  0000009c (device_type)
#  value type:       08 (string)
#  string size:      0000000b
#  value:            496e737472756d656e7473 ("Instrument")
#  ---------------------------------
#  item identifier:  0000009d (unknown)
#  value type:       01 (byte)
#  value:            02
#  ---------------------------------
#  item identifier:  0000009e (creator)
#  value type:       08 (string)
#  string size:      00000005
#  value:            436c616573 ("Claes")
#  ---------------------------------
#  item identifier:  0000009f (comment)
#  value type:       08 (string)
#  string size:      00000000
#  ---------------------------------
#  item identifier:  000000a1 (category)
#  value type:       08 (string)
#  string size:      00000004
#  value:            42617373 ("Bass")
#  ---------------------------------
#  item identifier:  000000a2 (tags)
#  value type:       08 (string)
#  string size:      0000000d
#  value:            6861726d6f6e6963206d6f6e6f ("harmonic mon")
#  ---------------------------------
#  item identifier   000000a3 (unknown) end of meta
#  value type:       05 (byte)
#  value:            01
#  ---------------------------------
#  item identifier:  0000137e (unknown)
#  value type:       05 (byte)
#  value:            01
#  ---------------------------------
#  .... don't need any more
#
# ----------------------------------------------
# Spire BA Agress Dub 02.bwpreset (1.0.10)
#
#  chunk identifier: 000001a5
#  ---------------------------------
#  item identifier:  000002b9 (unknown)
#  value identifier: 08 (string)
#  string size:      00000000
#  ---------------------------------
#  item identifier:  000012de (preset_name)
#  value type:       08 (string)
#  string size:      00000000
#  ---------------------------------
#  item identifier:  0000009a (device_name)
#  value type:       08 (string)
#  string size:      00000005
#  value:            5370697265 ("Spire")
#  ---------------------------------
#  item identifier:  0000009b (device_creator)
#  value type:       08 (string)
#  string size:      0000000c
#  value:            52657665616c20536f756e64 ("Reveal Sound")
#  ---------------------------------
#  item identifier:  0000009c (device_type)
#  value type:       08 (string)
#  string size:      00000005
#  value:            53796e7468 ("Synth")
#  ---------------------------------
#  item identifier:  0000009d (unknown)
#  value type:       01 (byte)
#  value:            02
#  ---------------------------------
#  item identifier:  0000009e (creator)
#  value type:       08 (string)
#  string size:      00000008
#  value:            466163746f727931 ("Factory1")
#  ---------------------------------
#  item identifier:  0000009f (comment)
#  value type:       08 (string)
#  string size:      00000000
#  ---------------------------------
#  item identifier:  000000a1 (category)
#  value type:       08 (string)
#  string size:      00000004
#  value:            42617373 ("Bass")
#  ---------------------------------
#  item identifier:  000000a2 (tags)
#  value type:       08 (string)
#  string size:      00000000
#  ---------------------------------
#  item identifier:  000000a3 (unknwon) end of meta
#  value type:       05 (byte)
#  value:            01
#  ---------------------------------
#  .... don't need any more

# constants
$ =
  magic: 'BtWg'
  metaId: 'meta'
  presetType:
    type1: 0x000001a5
    type2: 0x00000561
  valueType:
    byte_1: 0x01
    int16: 0x02
    int32_1: 0x03   # since 1.2 some preset file's revision_no use 32bit int
    byte_2: 0x05
    double: 0x07
    string: 0x08
    int32_2: 0x09
    byte_array: 0x0d
    string_array: 0x19
  protectedMetaItem:
    device_name: 0x009a
    device_creator: 0x009b
    device_category: 0x009c
  metaItem:
    name: 0x12de
    creator: 0x009e
    comment: 0x009f
    preset_category: 0x00a1
    tags: 0x00a2
  endOfMeta: 0x00a3
  # supported header format
  headers: [
    {
       regexp: /^BtWg[0-9a-f]{12}([0-9a-f]{8})0{8}([0-9a-f]{8})\u0000\u0000\u0000\u0004\u0000\u0000\u0000\u0004meta/
       size: 52
       contentAddress: 16
       zipContentAddress: 32
    }
    {
      regexp: /^BtWg[0-9a-f]{12}([0-9a-f]{8})0{8}([0-9a-f]{8})00\u0000\u0000\u0000\u0004\u0000\u0000\u0000\u0004meta/
      size: 54
      contentAddress: 16
      zipContentAddress: 32
    }
    {
      regexp: /^BtWg[0-9a-f]{12}([0-9a-f]{8})0{28}([0-9a-f]{8})\u0000\u0000\u0000\u0004\u0000\u0000\u0000\u0004meta/
      size: 72
      contentAddress: 16
      zipContentAddress: 52
    }
  ]

module.exports = (data) ->
  through.obj (file, enc, cb) ->
    rewrited = off
    rewrite = (err, data) =>
      if rewrited
        @emit 'error', new PluginError PLUGIN_NAME, 'duplicate callback'
        return
      rewrited = on
      if err
        @emit 'error', new PluginError PLUGIN_NAME, err
        return cb()
      try
        rewriteMeta file, data
        @push file
      catch err2
        @emit 'error', new PluginError PLUGIN_NAME, err2
      cb()
      
    unless file
      rewrite 'Files can not be empty'
      return
    
    if file.isNull()
      @push file
      return cb()

    if file.isStream()
      rewrite 'Streaming not supported'
      return

    if file.isBuffer()
      if _.isFunction data
        try
          obj = data file, (parseMetadata file), rewrite
        catch error
          rewrite error
        if data.length <= 2
          rewrite undefined, obj
      else
        rewrite undefined, data
#
# rewrite metadata
# -------------------------------------
# -file src file
# -data function or object for rewrite
rewriteMeta = (file, data) ->
  data = validateData data
  # analyze header
  headerStr = file.contents.toString 'ascii', 0, 80
  headerData = undefined
  headerFormat = $.headers.find (fmt) ->
    headerData = headerStr.match fmt.regexp
  unless headerFormat
    throw new Error "Invalid file: unknown header format. file:#{file.path} header:#{file.contents.toString 'hex', 0, 80}"
      
  # content data offset
  content_offset = parseInt headerData[1], 16
  # zip archive offset
  zip_content_offset = parseInt headerData[2], 16
  
  reader = new BufferReader file.contents
  writer = new BufferWriter
  reader.position headerFormat.size
  
  new_metadata = replaceMetadata reader, writer, data

  #  chunk1
  reader.position content_offset
  writer.push reader.mark()

  # write new content offset address to header
  writer.writeHexInt writer.tell(), headerFormat.contentAddress
  
  if new_metadata.type is 'application/bitwig-preset'
    replacePresetChunk1 reader, writer, data

  # has zipped content
  if zip_content_offset
    reader.position zip_content_offset
    writer.push reader.mark()
    # write zipped content offset address to header
    writer.writeHexInt writer.tell(), headerFormat.zipContentAddress

  writer.push reader.end()

  # setup output file
  extname = path.extname file.path
  if data.name
    new_metadata.name = data.name
    dirname = path.dirname file.path
    file.path = path.join dirname, data.name + extname
  else
    new_metadata.name = path.basename file.path, extname
  file.contents = writer.buffer()
  file.data = new_metadata

# parse metadata chunk
#
# return JSON object to explain metadata of original source file.
parseMetadata = (file) ->
  # analyze header
  headerStr = file.contents.toString 'ascii', 0, 80
  headerData = undefined
  headerFormat = $.headers.find (fmt) ->
    headerData = headerStr.match fmt.regexp
  unless headerFormat
    throw new Error "Invalid file: unknown header format. file:#{file.path} header:#{file.contents.toString 'hex', 0, 80}"

  reader = new BufferReader file.contents

  reader.position headerFormat.size
  
  extname = path.extname file.path
  ret =
    file: file.path
    name: path.basename file.path, extname
  # iterate metadata items
  while reader.readInt32() is 1
    # read key kength
    key = reader.readString()
    unless key
      throw new Error "Invalid file: metadata item name can not be empty. file:#{file.path}"
    valueType = reader.readByte()
    value = undefined
    switch valueType
      when $.valueType.string
        value = reader.readString()
        if key is 'tags'
          value = if value then value.split ' ' else []
      when $.valueType.int16 then value = reader.readInt16()
      when $.valueType.int32_1 then value = reader.readInt32()
      when $.valueType.double then value = reader.readDouble()
      when $.valueType.byte_array then value = reader.readBytes()
      when $.valueType.string_array
        size = reader.readInt32()
        value = for i in [0...size]
          reader.readString()
      else
        throw new Error "Unsupported file format: unknown value type. file:#{file.path} key:#{key} valueType:#{valueType}"
    ret[key] = value
  ret


# reprace metadata chunk
#
# return JSON object to explain metadata
replaceMetadata = (reader, writer, data) ->
  new_metadata = {}
  # iterate metadata items
  while reader.readInt32() is 1
    # read key kength
    key = reader.readString()
    unless key
      throw new Error "Invalid file: metadata item name can not be empty."
    valueType = reader.readByte()
    value = undefined
    switch valueType
      when $.valueType.string
        if (key in _.keys $.metaItem) and (key in _.keys data)
          writer.push reader.mark()
          value = data[key]
          if key is 'tags'
            writer.pushString value.join ' '
          else
            writer.pushString value
          reader.readString()
          reader.mark()
        else
          value = reader.readString()
          if key is 'tags'
            value = if value then value.split ' ' else []
      when $.valueType.int16 then value = reader.readInt16()
      when $.valueType.int32_1 then value = reader.readInt32()
      when $.valueType.double then value = reader.readDouble()
      when $.valueType.byte_array then value = reader.readBytes()
      when $.valueType.string_array
        size = reader.readInt32()
        value = for i in [0...size]
          reader.readString()
      else
        throw new Error "Unsupported file format: unknown value type. key: #{key} valueType:#{valueType}"
    new_metadata[key] = value
  new_metadata

# reprace chunk1 (.bwpreset only)
replacePresetChunk1 = (reader, writer, data) ->
  chunkId = reader.readInt32()
  # iterate chunk1 items
  while (itemId = reader.readInt32()) isnt $.endOfMeta
    value = undefined
    valueType = reader.readByte()
    key = _.findKey $.metaItem, (v, k, o) -> v is itemId
    if key and key of data
      if valueType isnt $.valueType.string
        throw new Error "Unsupported file format: unknow value type. valueType:#{valueType}"
      writer.push reader.mark()
      oldValue = reader.readString()
      value = if key is 'tags' then data.tags.join ' ' else data[key]
      # old preset file dosen't have name
      value = '' if key is 'name' and oldValue is ''
      writer.pushString value
      reader.mark()
    else
      switch valueType
        when $.valueType.byte_1 then value = reader.readByte()
        when $.valueType.byte_2 then value = reader.readByte()
        when $.valueType.string then value = reader.readString()
        when $.valueType.int32_2 then value = reader.readInt32()
        else
          throw new Error "Unsupported File Format: unknown value type. itemId:#{itemId} valueType:#{valueType}"
  
#----------------------------------------
# validate data object for rewrite
#----------------------------------------
validateData = (data) ->
  data = data or {}
  keys = _.keys data
  if ('name' in keys) and not _.isString data.name
    throw new Error "option name must be string. name: #{data.name}"
    
  if ('creator' in keys) and not _.isString data.creator
    throw new Error "option creator must be string. creator: #{data.creator}"
      
  if ('comment' in keys) and not _.isString data.comment
    throw new Error "option comment must be string. comment: #{data.comment}"
      
  if ('preset_category' in keys) and not _.isString data.preset_category
    throw new Error "option preset_category must be string. preset_category: #{data.preset_category}"
    
  if 'tags' in keys
    unless _.isArray data.tags
      throw new Error "option tags must be array of strings. tags: #{data.tags}"
    for tag in data.tags
      unless _.isString tag
        throw new Error "option tags must be array of strings. tags: #{data.tags}"
      if (tag.indexOf ' ') >= 0
        throw new Error "tag can't contain spaces. tags: #{tag}"
  data

#----------------------------------------
# simple reader class
#----------------------------------------
class BufferReader
  constructor: (buf) ->
    @buf = buf
    @marker = 0
    @pos = 0

  skip: (n) ->
    @pos += n
    @
    
  position: (pos) ->
    @pos = pos
    @
      
  tell: ->
    @pos
    
  readInt32: ->
    ret = @buf.readUInt32BE @pos
    @pos += 4
    ret
    
  readInt16: ->
    ret = @buf.readUInt16BE @pos
    @pos += 2
    ret

  readDouble: ->
    ret = @buf.readDoubleBE @pos
    @pos += 8
    ret
     
  readByte: ->
    ret = @buf.readUInt8(@pos)
    @pos += 1
    ret
    
  readHexInt: ->
    s = @buf.toString 'ascii',@pos, @pos + 8
    @pos += 8
    parseInt s, 16

  readBytes: (len) ->
    unless len
      len = @readInt32()
    ret = ''
    if len
      ret = @buf.toString 'hex',@pos, @pos + len
      @pos += len
    ret
    
  readString: (len) ->
    enc = 'utf-8'
    unless len
      len = @readInt32()
    if len & 0x80000000
      enc = 'ucs2'
      len = (len & 0x7ffffffff) << 1
    ret = ''
    if len
      b = @buf.slice @pos, @pos + len
      if IS_UCS2_LE and enc is 'ucs2'
        b = swapBytes b
      ret = b.toString enc, 0, len
      @pos += len
    ret

  mark: ->
    ret = @buf.slice @marker, @pos
    @marker = @pos
    ret
    
  end: ->
    @buf.slice @marker
    
#----------------------------------------
# simple writer class
#----------------------------------------
class BufferWriter
  constructor: ->
    @buf = Buffer.alloc 0

  buffer: ->
    @buf
    
  tell: ->
    @buf.length
      
  writeHexInt: (num, offset) ->
    s = "00000000#{num.toString 16}"[-8..]
    @buf.write s, offset, 8, 'ascii'
    
  push: (buf, start, end) ->
    b = buf
    if _.isNumber start
      if _.isNumber end
        b = buf.slice start, end
      else
        b = buf.slice start
    @buf = Buffer.concat [@buf, b]
    @
  
  pushInt: (value) ->
    b = Buffer.alloc 4
    b.writeUInt32BE value, 0
    @push b
    @
    
  pushString: (value) ->
    if value
      if /^[\u0000-\u007f]*$/.test value
        # ascii
        b = Buffer.from value, 'ascii'
        @pushInt b.length
        @push b if b.length
      else
        # value has non-ascii characters
        b = Buffer.from value, 'ucs2'
        if IS_UCS2_LE
          b = swapBytes b
        @pushInt 0x80000000 + (b.length >> 1)
        @push b if b.length
    else
      @pushInt 0
    @

swapBytes = (b) ->
  l = b.length >> 1
  for i in [0...l]
    p = i << 1
    a = b[p]
    b[p] = b[p + 1]
    b[p + 1] = a
  b
