#
# mail.rb
#
#   Copyright (c) 1998-2001 Minero Aoki <aamine@loveruby.net>
#
#   This program is free software.
#   You can distribute/modify this program under the terms of
#   the GNU Lesser General Public License version 2 or later.
#

require 'socket'
require 'amstd/bug'
require 'tmail/facade'
require 'tmail/encode'
require 'tmail/field'
require 'tmail/port'


module TMail

  class MailError < StandardError; end
  class MailSyntaxError < MailError; end


  class << self

    def new_boundary
      'mimepart_' + random_tag
    end

    alias boundary new_boundary

    def new_message_id( fqdn = nil )
      fqdn ||= ::Socket.gethostname
      "<#{random_tag()}@#{fqdn}.tmail>"
    end

    alias msgid     new_message_id
    alias new_msgid new_message_id

    private

    def random_tag
      @uniq += 1
      t = Time.now
      sprintf( '%x%x_%x%x%d%x',
               t.to_i,
               t.tv_usec,
               $$,
               Thread.current.id,
               @uniq,
               rand(255) )
    end

  end

  @uniq = 0


  class Mail

    class << self

      def load( fname )
        new FilePort.new( fname )
      end

      alias load_from load
      alias loadfrom load

      def parse( str )
        new StringPort.new(str)
      end

      # for compatibility

      def boundary
        TMail.new_boundary
      end

      def msgid
        TMail.new_msgid
      end

    end


    def initialize( port = nil, strict = false )
      @port = port || StringPort.new('')
      @strict = strict

      @header    = {}
      @body_port = nil
      @epilogue  = ''
      @parts     = []

      parse_header
    end

    attr :port

    def inspect
      "\#<#{type} port=#{@port.inspect} bodyport=#{@body_port.inspect}>"
    end


    include StrategyInterface

    def write_back( eol = "\n", charset = 'e' )
      @port.wopen {|stream| encoded eol, charset, stream }
    end

    def accept( strategy, f, sep = '' )
      with_multipart_encoding( strategy, f ) {
        ordered_each do |name, field|
          field.accept strategy
          f.puts
        end
        f.puts sep

        body_port.ropen do |stream|
          stream.add_to f
        end
      }
    end

    def with_multipart_encoding( strategy, f )
      if parts().empty? then    # DO NOT USE @parts
        yield

      else
        bound = ::TMail.new_boundary
        if @header.key? 'content-type' then
          @header['content-type'].params['boundary'] = bound
        else
          store 'Content-Type', %<multipart/mixed; boundary="#{bound}">
        end

        yield

        parts().each do |tm|
          f.puts
          f.puts '--' + bound
          tm.accept strategy, f
        end
        f.puts
        f.puts '--' + bound + '--'
        f.write epilogue()
      end
    end
    private :with_multipart_encoding



    ###
    ### header
    ###


    USE_ARRAY = {
      'received'          => true,
      'resent-date'       => true,
      'resent-from'       => true,
      'resent-sender'     => true,
      'resent-to'         => true,
      'resent-cc'         => true,
      'resent-bcc'        => true,
      'resent-message-id' => true,
      'comments'          => true,
      'keywords'          => true
    }

    def header
      @header.dup
    end

    def fetch( key, initbody = nil, &block )
      dkey = key.downcase
      ret = @header[ dkey ]

      unless ret then
        if iterator? then
          initbody = yield
        end
        if initbody then
          ret = mkhf( *ret.split(':', 2) )
          do_store dkey, ret
        end
      end

      ret
    end

    def store( key, val )
      dkey = key.downcase

      if val.nil? then
        @header.delete dkey
        return nil
      end

      case val
      when String
        val = mkhf( key, val )
      when HeaderField
        ;
      when Array
        unless USE_ARRAY.include? dkey then
          raise ArgumentError, "#{key}: Header must not be multiple"
        end
        @header[dkey] = val
        return val
      else
        val = mkhf( key, val.to_s )
      end

      if USE_ARRAY.include? dkey then
        if arr = @header[dkey] then
          arr.push val
          arr
        else
          @header[dkey] = [val]
        end
      else
        @header[dkey] = val
      end
    end

    def []( key )
      @header[ key.downcase ]
    end

    alias []= store


    def each_header( &block )
      @header.each {|k,v| yield_h k, v, &block }
    end

    alias each_pair each_header

    def each_header_name( &block )
      @header.each_key( &block )
    end

    alias each_key each_header_name

    def each_field( &block )
      @header.each_key do |key|
        v = fetch( key )
        if Array === v then
          v.each( &block )
        else
          yield v
        end
      end
    end

    alias each_value each_field

    FIELD_ORDER = %w(
      return-path received
      resent-date resent-from resent-sender resent-to
      resent-cc resent-bcc resent-message-id
      date from sender reply-to to cc bcc
      message-id in-reply-to references
      subject comments keywords
      mime-version content-type content-transfer-encoding
      content-disposition content-description
    )

    def ordered_each( &block )
      tmp = @header.dup
      FIELD_ORDER.each do |name|
        if f = tmp.delete(name) then
          yield_h name, f, &block
        end
      end
      tmp.each {|name, f| yield_h name, f, &block }
    end


    def clear
      @header.clear
    end

    def delete( key )
      @header.delete key.downcase
    end

    def delete_if
      @header.delete_if do |key,v|
        v = fetch( key )
        if Array === v then
          v.delete_if{|f| yield key, f }
          v.empty?
        else
          yield key, v
        end
      end
    end


    def keys
      @header.keys
    end

    def has_key?( key )
      @header.has_key? key.downcase
    end

    alias include? has_key?
    alias key?     has_key?


    def values
      ret = []
      each_field {|v| ret.push v }
      ret
    end

    def has_value?( val )
      return false unless HeaderField === val

      v = fetch( val.name )
      if Array === v then v.include? val
      else                v ? (val == v) : false
      end
    end

    alias value? has_value?


    def indexes( *args )
      args.collect {|k| fetch k }.flatten
    end

    alias indices indexes


    private


    def yield_h( name, field )
      if Array === field then
        field.each do |f|
          yield name, f
        end
      else
        yield name, field
      end
    end


    def mkhf( fname, fbody )
      fname.strip!
      HeaderField.new( fname, fbody, @strict )
    end

    def parse_header
      fname = fbody = nil
      unixfrom = nil
      errlog = []

      begin
        @stream = src = @port.ropen

        while line = src.gets do     # no each !
          case line
          when /\A[ \t]/             # continue from prev line
            unless fbody then
              errlog.push 'mail is began by space or tab'
              next
            end
            line.strip!
            fbody << ' ' << line
            # fbody << line

          when /\A([^\: \t]+):\s*/   # new header line
            add_hf fname, fbody if fbody
            fname = $1
            fbody = $'
            # fbody.strip!

          when /\A\-*\s*\z/          # end of header
            add_hf fname, fbody if fbody
            fname = fbody = nil
            break

          when /\AFrom (\S+)/
            unixfrom = $1

          else
            errlog.push "wrong mail header: '#{line.inspect}'"
          end
        end
        add_hf fname, fbody if fname
      ensure
        src.stop if src
      end
      errlog.empty? or raise MailSyntaxError, "\n" + errlog.join("\n")

      if unixfrom then
        add_hf 'Return-Path', "<#{unixfrom}>" unless @header['return-path']
      end
    end

    def add_hf( fname, fbody )
      key = fname.downcase
      hf = mkhf( fname, fbody )

      if USE_ARRAY.include? key then
        if tmp = @header[key] then
          tmp.push hf
        else
          @header[key] = [hf]
        end
      else
        @header[key] = hf
      end
    end



    ###
    ### body
    ###

    public


    def body_port
      parse_body
      @body_port
    end

    def each
      body_port.ropen do |f|
        f.each {|line| yield line }
      end
    end

    def body
      parse_body
      ret = nil
      @body_port.ropen {|is| ret = is.read_all }
      ret
    end

    def body=( str )
      parse_body
      @body_port.wopen {|os| os.write str }
      true
    end

    alias preamble  body
    alias preamble= body=

    def epilogue
      parse_body
      @epilogue.dup
    end

    def epilogue=( str )
      parse_body
      @epilogue = str
      str
    end

    def parts
      parse_body
      @parts
    end


    private


    def parse_body
      if @stream then
        parse_body_in @stream
        @stream = nil
      end
    end
    
    def parse_body_in( stream )
      begin
        stream.restart
        if multipart? then
          read_multipart stream
        else
          @body_port = tmp_port(0)
          @body_port.wopen do |f|
            stream.copy_to f
          end
        end
      ensure
        stream.close unless stream.closed?
      end
    end

    def read_multipart( src )
      bound = self['content-type'].params['boundary']
      is_sep = /\A--#{Regexp.quote bound}(?:--)?[ \t]*(?:\n|\r\n|\r)/
      lastbound = "--#{bound}--"

      f = nil
      n = 1
      begin
        ports = []
        ports.push tmp_port(0)
        f = ports[-1].wopen

        while line = src.gets do    # no each !
          if is_sep === line then
            f.close
            line.strip!
            if line == lastbound then
              break
            else
              ports.push tmp_port(n)
              n += 1
              f = ports[-1].wopen
            end
          else
            f << line
          end
        end
        @epilogue = src.read_all
      ensure
        f.close if f and not f.closed?
      end

      @body_port = ports.shift
      @parts = ports.collect {|p| type.new p }
    end

    def tmp_port( n )
      StringPort.new ''
    end

  end   # class Mail

end   # module TMail
