require "mp3info/extension_modules"

# This class is not intended to be used directly
class ID3v2 < DelegateClass(Hash) 
  VERSION_MAJ = 3
  
  TAGS = {
    "AENC" => "Audio encryption",
    "APIC" => "Attached picture",
    "COMM" => "Comments",
    "COMR" => "Commercial frame",
    "ENCR" => "Encryption method registration",
    "EQUA" => "Equalization",
    "ETCO" => "Event timing codes",
    "GEOB" => "General encapsulated object",
    "GRID" => "Group identification registration",
    "IPLS" => "Involved people list",
    "LINK" => "Linked information",
    "MCDI" => "Music CD identifier",
    "MLLT" => "MPEG location lookup table",
    "OWNE" => "Ownership frame",
    "PRIV" => "Private frame",
    "PCNT" => "Play counter",
    "POPM" => "Popularimeter",
    "POSS" => "Position synchronisation frame",
    "RBUF" => "Recommended buffer size",
    "RVAD" => "Relative volume adjustment",
    "RVRB" => "Reverb",
    "SYLT" => "Synchronized lyric/text",
    "SYTC" => "Synchronized tempo codes",
    "TALB" => "Album/Movie/Show title",
    "TBPM" => "BPM (beats per minute)",
    "TCOM" => "Composer",
    "TCON" => "Content type",
    "TCOP" => "Copyright message",
    "TDAT" => "Date",
    "TDLY" => "Playlist delay",
    "TENC" => "Encoded by",
    "TEXT" => "Lyricist/Text writer",
    "TFLT" => "File type",
    "TIME" => "Time",
    "TIT1" => "Content group description",
    "TIT2" => "Title/songname/content description",
    "TIT3" => "Subtitle/Description refinement",
    "TKEY" => "Initial key",
    "TLAN" => "Language(s)",
    "TLEN" => "Length",
    "TMED" => "Media type",
    "TOAL" => "Original album/movie/show title",
    "TOFN" => "Original filename",
    "TOLY" => "Original lyricist(s)/text writer(s)",
    "TOPE" => "Original artist(s)/performer(s)",
    "TORY" => "Original release year",
    "TOWN" => "File owner/licensee",
    "TPE1" => "Lead performer(s)/Soloist(s)",
    "TPE2" => "Band/orchestra/accompaniment",
    "TPE3" => "Conductor/performer refinement",
    "TPE4" => "Interpreted, remixed, or otherwise modified by",
    "TPOS" => "Part of a set",
    "TPUB" => "Publisher",
    "TRCK" => "Track number/Position in set",
    "TRDA" => "Recording dates",
    "TRSN" => "Internet radio station name",
    "TRSO" => "Internet radio station owner",
    "TSIZ" => "Size",
    "TSRC" => "ISRC (international standard recording code)",
    "TSSE" => "Software/Hardware and settings used for encoding",
    "TYER" => "Year",
    "TXXX" => "User defined text information frame",
    "UFID" => "Unique file identifier",
    "USER" => "Terms of use",
    "USLT" => "Unsychronized lyric/text transcription",
    "WCOM" => "Commercial information",
    "WCOP" => "Copyright/Legal information",
    "WOAF" => "Official audio file webpage",
    "WOAR" => "Official artist/performer webpage",
    "WOAS" => "Official audio source webpage",
    "WORS" => "Official internet radio station homepage",
    "WPAY" => "Payment",
    "WPUB" => "Publishers official webpage",
    "WXXX" => "User defined URL link frame"
  }

  include Mp3Info::HashKeys
  
  attr_reader :io_position

  # possibles keys:
  # :+lang+ for writing comments
  # :+encoding+: :+iso+ or :+unicode+ 
  attr_reader :options
  
  def initialize(options = {})
    @options = { 
      :lang => "ENG", 
      :encoding => :iso  #language encoding bit 0 for iso_8859_1, 1 for unicode
    }
    @options.update(options)
    
    @hash = {}
    #TAGS.keys.each { |k| @hash[k] = nil }
    @hash_orig = {}
    super(@hash)
    @valid = false
    @version_maj = @version_min = nil
  end

  def valid?
    @valid
  end

  def changed?
    @hash_orig != @hash
  end
  
  def version
    "2.#{@version_maj}.#{@version_min}"
  end

  ### gets id3v2 tag information from io
  def from_io(io)
    @io = io
    version_maj, version_min, flags = @io.read(3).unpack("CCB4")
    unsync, ext_header, experimental, footer = (0..3).collect { |i| flags[i].chr == '1' }
    raise("can't find version_maj ('#{version_maj}')") unless [2, 3, 4].include?(version_maj)
    @version_maj, @version_min = version_maj, version_min
    @valid = true
    tag2_len = @io.get_syncsafe
    case @version_maj
      when 2
        read_id3v2_2_frames(tag2_len)
      when 3,4
        # seek past extended header if present
        @io.seek(@io.get_syncsafe - 4, IO::SEEK_CUR) if ext_header
        read_id3v2_3_frames(tag2_len)
    end
    @io_position = @io.pos
    
    @hash_orig = @hash.dup
    #no more reading
    @io = nil
    # we should now have io sitting at the first MPEG frame
  end

  def to_bin
    #TODO handle of @tag2[TLEN"]
    #TODO add of crc
    #TODO add restrictions tag

    tag = ""
    @hash.each do |k, v|
      next unless v
      next if v.respond_to?("empty?") and v.empty?
      data = encode_tag(k, v.to_s)
      #data << "\x00"*2 #End of tag

      tag << k[0,4]   #4 characte max for a tag's key
      #tag << to_syncsafe(data.size) #+1 because of the language encoding byte
      tag << [data.size].pack("N") #+1 because of the language encoding byte
      tag << "\x00"*2 #flags
      tag << data
    end

    tag_str = ""
    #version_maj, version_min, unsync, ext_header, experimental, footer 
    tag_str << [ VERSION_MAJ, 0, "0000" ].pack("CCB4")
    tag_str << to_syncsafe(tag.size)
    tag_str << tag
    p tag_str if $DEBUG
    tag_str
  end

  private


  def encode_tag(name, value)
    puts "encode_tag(#{name.inspect}, #{value.inspect})" if $DEBUG
    case name
      when "COMM"
	[ @options[:encoding] == :iso ? 0 : 1, @options[:lang], 0, value ].pack("ca3ca*")
      else
        if @options[:encoding] == :iso
	  "\x00"+value
	else
	  "\x01"+value #Iconv.iconv("UNICODE", "ISO-8859-1", value)[0]
	end
      #data << "\x00"   
    end
  end

  ### Read a tag from file and perform UNICODE translation if needed
  def decode_tag(name, value)
    case name
      when "COMM"
        #FIXME improve this
	encoding, lang, str = value.unpack("ca3a*")
	out = value.split(0.chr).last
      else
	encoding = value[0]     # language encoding bit 0 for iso_8859_1, 1 for unicode
	out = value[1..-1] 
    end

    if encoding == 1 #and name[0] == ?T
      require "iconv"
      
      #strip byte-order bytes at the beginning of the unicode string if they exists
      out[0..3] =~ /^[\xff\xfe]+$/ and out = out[2..-1]
      begin
	out = Iconv.iconv("ISO-8859-1", "UNICODE", out)[0] 
      rescue Iconv::IllegalSequence, Iconv::InvalidCharacter
      end
    end
    out
  end

  ### reads id3 ver 2.3.x/2.4.x frames and adds the contents to @tag2 hash
  ###  tag2_len (fixnum) = length of entire id3v2 data, as reported in header
  ### NOTE: the id3v2 header does not take padding zero's into consideration
  def read_id3v2_3_frames(tag2_len)
    loop do # there are 2 ways to end the loop
      name = @io.read(4)
      if name[0] == 0 or name == "MP3e" #bug caused by old tagging application "mp3ext" ( http://www.mutschler.de/mp3ext/ )
        @io.seek(-4, IO::SEEK_CUR)    # 1. find a padding zero,
	seek_to_v2_end
        break
      else
        #size = @file.get_syncsafe #this seems to be a bug
        size = @io.get32bits
        puts "name '#{name}' size #{size} " if $DEBUG
        @io.seek(2, IO::SEEK_CUR)     # skip flags
        add_value_to_tag2(name, size)
#        case name
#          when /^T/
#            puts "tag is text. reading" if $DEBUG
##	    data = read_id3_string(size-1)
##	    add_value_to_tag2(name, data)
#          else
#	    decode_tag(
#            #@file.seek(size-1, IO::SEEK_CUR)  
#            puts "tag is binary, skipping" if $DEBUG
#            @io.seek(size, IO::SEEK_CUR)  
#        end

#	case name
#	  #FIXME DRY
#	  when "COMM"
#            data = read_id3v2_frame(size)
#	    lang = data[0,3]
#	    data = data[3,-1]
#	  else
#	end
      end
      break if @io.pos >= tag2_len # 2. reach length from header
    end
  end    

  ### reads id3 ver 2.2.x frames and adds the contents to @tag2 hash
  ###  tag2_len (fixnum) = length of entire id3v2 data, as reported in header
  ### NOTE: the id3v2 header does not take padding zero's into consideration
  def read_id3v2_2_frames(tag2_len)
    loop do
      name = @io.read(3)
      if name[0] == 0
        @io.seek(-3, IO::SEEK_CUR)
	seek_to_v2_end
        break
      else
        size = (@io.getc << 16) + (@io.getc << 8) + @io.getc
	add_value_to_tag2(name, size)
        break if @io.pos >= tag2_len
      end
    end
  end    
  
  ### Add data to tag2["name"]
  ### read lang_encoding, decode data if unicode and
  ### create an array if the key already exists in the tag
  def add_value_to_tag2(name, size)
    puts "add_value_to_tag2" if $DEBUG
    data = decode_tag(name, @io.read(size))
    if self.keys.include?(name)
      unless self[name].is_a?(Array)
        self[name] = self[name].to_a
      end
      self[name] << data
    else
      self[name] = data 
    end
  end
  
  ### runs thru @file one char at a time looking for best guess of first MPEG
  ###  frame, which should be first 0xff byte after id3v2 padding zero's
  def seek_to_v2_end
    until @io.getc == 0xff
    end
    @io.seek(-1, IO::SEEK_CUR)
  end
  
  ### convert an 32 integer to a syncsafe string
  def to_syncsafe(num)
    n = ( (num<<3) & 0x7f000000 )  + ( (num<<2) & 0x7f0000 ) + ( (num<<1) & 0x7f00 ) + ( num & 0x7f )
    [n].pack("N")
  end

#  def method_missing(meth, *args)
#    m = meth.id2name
#    return nil if TAGS.has_key?(m) and self[m].nil?
#    super
#  end
end

