#
# Copyright (C) 2002-2004 Satoru Takabayashi <satoru@namazu.org> 
# Copyright (C) 2003-2005 Kouichirou Eto
#     All rights reserved.
#     This is free software with ABSOLUTELY NO WARRANTY.
#
# You can redistribute it and/or modify it under the terms of 
# the GNU General Public License version 2.
#

$LOAD_PATH << "../../lib" unless $LOAD_PATH.include?("../../lib")
require "qwik/ml-config"
require "qwik/ml-gettext"
require "qwik/ml-core-utils"
require "qwik/ml-site"

module QuickML
  class QuickML
    include GetText

    def nu_initialize (config, address, creator = nil, message_charset = nil)
      @config = config
      @address = address
      @name = get_name(@address)
      raise InvalidMLName unless QuickML.valid_name?(@name)
      @short_name = @name.split("@").first

      @return_address = generate_return_address

      @members_file = File.join(@config.data_dir, @name)
      @count_file = File.join(@config.data_dir, @name + ",count")
      @forwardp_file = File.join(@config.data_dir, @name + ",forward")
      @alertedp_file = File.join(@config.data_dir, @name + ",alerted")
      @permanentp_file = File.join(@config.data_dir, @name + ",permanent")
      @unlimitedp_file = File.join(@config.data_dir, @name + ",unlimited")
      @charset_file = File.join(@config.data_dir, @name + ",charset")
      @ml_config_file = File.join(@config.data_dir, @name + ",config")

      @waiting_members_file = File.join(@config.data_dir, @name + 
                                        ",waiting-members")
      @waiting_message_file = File.join(@config.data_dir, @name + 
                                        ",waiting-message")
      @member_added_p = false
      @added_members = []

      @logger = @config.logger
      @catalog = @config.catalog

      init_ml_config
      init_members
      init_count
      init_charset

      @message_charset = (message_charset or @charset)
      @logger.log "[#{@name}]: New ML by #{creator}" if newly_created?
    end

    def initialize (config, address, creator = nil, message_charset = nil)
      #qp config
      #qp "hi"
      @config = config
      @address = address
      @name = get_name(@address)
      raise InvalidMLName unless QuickML.valid_name?(@name)
      @short_name = @name.split("@").first

      @return_address = generate_return_address

      init_files
      @member_added_p = false
      @added_members = []

      @logger = @config.logger
      @catalog = @config.catalog

      init_ml_config
      init_members
      init_count
      init_charset

      @message_charset = (message_charset or @charset)
      @logger.log "[#{@name}]: New ML by #{creator}" if newly_created?
    end

    def init_files
      # original QuickML
      #name = @name
      #dir = @config.data_dir
      
      # for qwikWeb
      name = ""
      dir = File.join(@config.data_dir, @name)
      Dir.mkdir(dir) unless FileTest.directory?(dir)

      @members_file = File.join(dir, name + ",members")
      @count_file = File.join(dir, name + ",count")
      @forwardp_file = File.join(dir, name + ",forward")
      @alertedp_file = File.join(dir, name + ",alerted")
      @permanentp_file = File.join(dir, name + ",permanent")
      @unlimitedp_file = File.join(dir, name + ",unlimited")
      @charset_file = File.join(dir, name + ",charset")
      @ml_config_file = File.join(dir, name + ",config")
      @waiting_members_file = File.join(dir, name + ",waiting-members")
      @waiting_message_file = File.join(dir, name + ",waiting-message")
    end

    attr_reader :name
    attr_reader :short_name
    attr_reader :active_members
    attr_reader :former_members
    attr_reader :address
    attr_reader :return_address
    attr_reader :count
    attr_reader :charset
    attr_reader :max_members

    def self.valid_name? (name)
    # /^([0-9a-zA-Z_.-]+)(@[0-9a-zA-Z_.-]+)?$/ =~ name

      # do not allow "_" and "." for short_name
      /^([0-9a-zA-Z-]+)(@[0-9a-zA-Z_.-]+)?$/ =~ name
    end

    def exclude? (address)
      name, domain = address.split("@")
      Mail.address_of_domain?(address, @config.domain) or domain.nil?
    end

    def ml_config_exist?
      File.exist?(@ml_config_file)
    end

    def write_ml_config
      File.safe_open(@ml_config_file, "w") {|f|
	f.puts "{"
	f.printf("  :%s => %d,\n", :max_members,     @max_members)
	f.printf("  :%s => %d,\n", :max_mail_length, @max_mail_length)
	f.printf("  :%s => %d,\n", :ml_life_time,    @ml_life_time)
	f.printf("  :%s => %d,\n", :ml_alert_time,   @ml_alert_time)
	f.printf("  :%s => %d,\n", :auto_unsubscribe_count, 
                 @auto_unsubscribe_count)
	f.puts "}"
      }
    end

    def nu_send_confirmation (creator_address)
      header = []
      subject = Mail.encode_field(_("[%s] Confirmation: %s",
				    @short_name, @address))
      header.push(["To",	creator_address],
		  ["From",	confirmation_address],
		  ["Subject",	subject],
                  ["Content-Type", content_type])

      body = _("Please simply reply this mail to create ML <%s>.\n",
               @address)
      Mail.send_mail(@config.smtp_host, @config.smtp_port, @logger, 
		     :mail_from => "", 
		     :recipient => creator_address,
		     :header => header,
		     :body => body)
      @logger.log "[#{@name}]: Send confirmation: #{confirmation_address} #{creator_address}"
    end

    def send_confirmation (creator_address)
      header = []
      subject = Mail.encode_field(_("[%s] Confirmation: %s",
				    @short_name, @address))
      header.push(["To",	creator_address],
		  ["From",	confirmation_address],
		  ["Subject",	subject],
                  ["Content-Type", content_type])

      body = confirmation_message(@address)
      Mail.send_mail(@config.smtp_host, @config.smtp_port, @logger, 
		     :mail_from => "", 
		     :recipient => creator_address,
		     :header => header,
		     :body => body)
      @logger.log "[#{@name}]: Send confirmation: #{confirmation_address} #{creator_address}"
    end

    def confirmation_message(address)
      body = ""
      body += _("At the first, please read the agreement of this service.\n")
      #body += _("http://example.com/qwikjpAgreementE.html\n")
      body += _("http://qwik.jp/qwikjpAgreementE.html\n")
      body += _("You have to agree this agreement to use this service.\n")
      body += _("If you agree, then,\n")
      body += _("Please simply reply this mail to create ML <%s>.\n",
               address)
      body
    end

    def submit (mail)
      return if @active_members.empty?

      if mail.body.length > @max_mail_length
        report_too_large_mail(mail)
        @logger.log "[#{@name}]: Too Large Mail: #{mail.from}"
      else 
        reset_error_member(mail.from)
        start_time = Time.now
        _submit(mail)
        elapsed = Time.now - start_time
        @logger.log "[#{@name}:#{@count}]: Send: #{@config.smtp_host} #{elapsed} sec."
      end
    end

    def inactive?
      return false if forward? or permanent?
      last_article_time + @ml_life_time < Time.now
    end

    def need_alert?
      return false if forward? or permanent?
      alert_time = last_article_time + @ml_alert_time
      now = Time.now
      alert_time <= now && !alerted?
    end

    # FIXME: too similar to report_too_large_mail in server.rb
    def report_too_large_mail (mail)
      header = []
      subject = Mail.encode_field(_("[QuickML] Error: %s", mail["Subject"]))
      header.push(["To",	mail.from],
		  ["From",	@address],
		  ["Subject",	subject],
		  ["Content-Type", content_type])
      max  = @max_mail_length.commify
      body =   _("Sorry, your mail exceeds the limitation of the length.\n")
      body <<  _("The max length is %s bytes.\n\n", max)
      orig_subject = codeconv(Mail.decode_subject(mail["Subject"]))
      body << "Subject: #{orig_subject}\n"
      body << "To: #{mail['To']}\n"
      body << "From: #{mail['From']}\n"
      body << "Date: #{mail['Date']}\n"
      Mail.send_mail(@config.smtp_host, @config.smtp_port, @logger,
		     :mail_from => "", 
		     :recipient => mail.from,
		     :header => header,
		     :body => body)
    end

    def report_removed_member (error_address)
      return if @active_members.empty?
      subject = Mail.encode_field(_("[%s] Removed: <%s>", 
				    @short_name, error_address))
      header = []
      header.push(["To",	@address],
		  ["From",	@address],
		  ["Subject",	subject],
		  ["Reply-To",	@address],
		  ["Content-Type", content_type])
      header.concat(quickml_fields)

      body =  _("<%s> was removed from the mailing list:\n<%s>\n", 
		error_address, @address)
      body << _("because the address was unreachable.\n")
      body << generate_footer(true)

      Mail.send_mail(@config.smtp_host, @config.smtp_port, @logger, 
		     :mail_from => "", 
		     :recipients => @active_members,
		     :header => header,
		     :body => body)
      @logger.log "[#{@name}]: Notify: Remove #{error_address}"
    end

    def report_ml_close_soon
      return if @active_members.empty?
      subject = Mail.encode_field(_("[%s] ML will be closed soon", 
				    @short_name))

      header = []
      header.push(["To",	@address],
		  ["From",	@address],
		  ["Subject",	subject],
		  ["Reply-To",	@address],
		  ["Content-Type", content_type])
      header.concat(quickml_fields)

      time_to_close = last_article_time + @ml_life_time
      ndays = ((time_to_close - Time.now) / 86400.0).ceil
      datefmt = __("%Y-%m-%d %H:%M")

      body =  _("ML will be closed if no article is posted for %d days.\n\n",
		ndays)
      body << _("Time to close: %s.\n\n", time_to_close.strftime(datefmt))
      body << generate_footer(true)

      Mail.send_mail(@config.smtp_host, @config.smtp_port, @logger,
		     :mail_from => "", 
		     :recipients => @active_members,
		     :header => header,
		     :body => body)
      @logger.log "[#{@name}]: Alert: ML will be closed soon"
      File.safe_open(@alertedp_file, "w").close
    end

    def close
      File.safe_unlink(@members_file)
      File.safe_unlink(@count_file)
      File.safe_unlink(@charset_file)
      File.safe_unlink(@alertedp_file)
      File.safe_unlink(@waiting_members_file)
      File.safe_unlink(@waiting_message_file)
      File.safe_unlink(@ml_config_file)
      @logger.log "[#{@name}]: ML Closed"
    end

    def forward?
      File.exist?(@forwardp_file)
    end

    def permanent?
      File.exist?(@permanentp_file)
    end

    def unlimited?
      File.exist?(@unlimitedp_file)
    end

    def newly_created?
      !File.exist?(@members_file)
    end

    def confirmation_waiting?
      File.exist?(@waiting_members_file)
    end

    def alerted?
      File.exist?(@alertedp_file)
    end

    def validate_confirmation (time)
      File.exist?(@waiting_message_file) and 
        File.mtime(@waiting_message_file).to_i == time.to_i
    end

    def prepare_confirmation (mail)
      save_member_file # to create empty ML files.
      File.safe_open(@waiting_message_file, "w") {|f| f.print(mail.bare) }
      add_waiting_member(mail.from)
      mail.collect_cc.each {|address| 
        add_waiting_member(address)
      }
      send_confirmation(mail.from)
    end

    def accept_confirmation
      waiting_members = 
	File.safe_open(@waiting_members_file).readlines.map {|line| 
        line.chomp
      }
      waiting_message = File.safe_open(@waiting_message_file).read
      mail = Mail.new
      mail.read(waiting_message)

      waiting_members.each {|address|
        begin
          add_member(address)
        rescue TooManyMembers
        end
      }
      submit(mail)
      File.safe_unlink(@waiting_members_file)
      File.safe_unlink(@waiting_message_file)
      @logger.log "[#{@name}]: Accept confirmation: #{@addressconfirmation_address} #{@address}"
    end

    def add_waiting_member (address)
      File.safe_open(@waiting_members_file, "a") {|f|
        f.puts address
      }
    end

    def too_many_members?
      (not unlimited?) and (@active_members.length >= @max_members)
    end

    def remove_member (address)
      return unless @active_members.include?(address)
      @active_members.delete(address)
      @former_members.push(address)
      remove_error_member(address)
      save_member_file
      @logger.log "[#{@name}]: Remove: #{address}"
      close if @active_members.empty?
    end

    def add_member (address)
      if exclude?(address)
	@logger.vlog "Excluded: #{address}"
	return
      end
      return if @active_members.include?(address)
      raise TooManyMembers if too_many_members?
      @former_members.delete(address)
      @active_members.push(address)
      save_member_file
      @logger.log "[#{@name}]: Add: #{address}"
      @added_members.push(address)
      @member_added_p = true
    end

    def add_error_member (address)
      return unless @active_members.include?(address)
      prev_count = error_count(address)
      count = inc_error_count(address)
      if prev_count == count
	@logger.log "[#{@name}]: AddError: #{address} (not counted)"
      else
	@logger.log "[#{@name}]: AddError: #{address} #{count}"
      end
      save_member_file

      if error_count(address) >= @auto_unsubscribe_count
	remove_member(address)
	report_removed_member(address)
      end
    end

    private

    def init_ml_config
      ml_config = Hash.new
      begin
        eval(File.open(@ml_config_file).read).each {|key, value|
          ml_config[key] = value
        }
      rescue Exception
      end
      @max_members            = (ml_config[:max_members] or 
                                 @config.max_members)
      @max_mail_length        = (ml_config[:max_mail_length] or 
                                 @config.max_ml_mail_length or
                                 @config.max_mail_length)
      @ml_life_time           = (ml_config[:ml_life_time] or 
                                 @config.ml_life_time)
      @ml_alert_time          = (ml_config[:ml_alert_time] or 
                                 @config.ml_alert_time)
      @auto_unsubscribe_count = (ml_config[:auto_unsubscribe_count] or 
                                 @config.auto_unsubscribe_count)
      write_ml_config
    end

    def confirmation_address
      sprintf("confirm+%d+%s", 
              File.mtime(@waiting_message_file).to_i,
              @address)
    end

    def generate_return_address
      raise unless @address
      raise unless @short_name
      domain_part = @address.split("@").last
      if @config.use_qmail_verp
	# e.g. <foo=return=@quickml.com-@[]>
	@short_name + "=return=" + "@" + domain_part + "-@[]"
      else
	# e.g. <foo=return@quickml.com>
	@short_name + "=return" + "@" + domain_part
      end
    end

    def nu_last_article_time
      if File.file?(@count_file)  then 
	File.mtime(@count_file) 
      else 
	Time.now 
      end
    end

    def last_article_time # the mtime of the newest file in the directory
      max = maxfile = nil
      dir = File.dirname(@count_file)
      Dir.foreach(dir){|f|
	next if f =~ /^\./ || f =~ /,config/ || f =~ /,charset/
	file = File.join(dir, f)
	mt = File.mtime(file)
	if max.nil? || max < mt
	  max = mt 
	  maxfile = file
	end
      }
      max || Time.now
    end

    def get_name (address)
      raise InvalidMLName if /@.*@/ =~ address
      name, host = address.split("@")
      if host.nil?
	return name
      else
	fqdn = host.downcase
	domainpat = Regexp.new('^(.*)' + 
			       Regexp.quote("." + @config.domain) + '$') #'
	if domainpat =~ fqdn 
	  subdomain = $1
	  return name + "@" + subdomain
	else 
	  return name
	end
      end
    end

    def init_count
      @count = 0
      return unless File.exist?(@count_file)
      File.safe_open(@count_file, "r") {|f|
	@count = f.gets.chomp.to_i
      }
    end

    def inc_count
      @count += 1
      File.safe_open(@count_file, "w") {|f|
	f.puts @count
      }
    end

    def init_charset
      @charset = nil
      return unless File.exist?(@charset_file)
      File.safe_open(@charset_file, "r") {|f|
	@charset = f.gets.chomp
      }
    end

    def save_charset
      return if @message_charset.nil?
      File.safe_open(@charset_file, "w") {|f|
	f.puts @message_charset
      }
    end

    def init_members
      @active_members = IcaseArray.new
      @former_members = IcaseArray.new
      @error_members  = IcaseHash.new
      return unless File.exist?(@members_file)
      File.safe_open(@members_file, "r") {|f|
	f.each {|line| 
	  line.chomp!
	  if /^# (.*)/ =~ line  # removed address
	    @former_members.push($1) unless @former_members.include?($1)
	  elsif /^; (.*?) (\d+)(?: (\d+))?/ =~ line
	    address = $1
	    count= $2.to_i
	    last_error_time = if $3 then Time.at($3.to_i) else Time.at(0) end
	    @error_members[address]= ErrorInfo.new(count, last_error_time)
	  else
	    @active_members.push(line) unless @active_members.include?(line)
	  end
	}
      }
    end

    def save_member_file
      File.safe_open(@members_file, "w") {|f|
	@active_members.each {|address| f.puts address}
	@former_members.each {|address| f.puts "# " + address}
	@error_members.each {|address, error_info| 
	  f.printf("; %s %d %d\n", 
		   address, error_info.count, error_info.last_error_time.to_i)
	}
      }
    end

    # satoru@namazu.org => satoru@n...
    def obfuscate_address (address)
      address.sub(/(@.).*/, '\1...')
    end

    def member_list
      _("Members of <%s>:\n", @address) + 
	@active_members.map {|x| obfuscate_address(x)}.join("\n") + "\n"
    end

    def unsubscribe_info
      "\n" +
        _("How to unsubscribe from the ML:\n") +
        _("- Just send an empty message to <%s>.\n", @address) +
        _("- Or, if you cannot send an empty message for some reason,\n") +
        _("  please send a message just saying 'unsubscribe' to <%s>.\n", @address) +
        _("  (e.g., hotmail's advertisement, signature, etc.)\n")
    end

    def generate_header
      header  = sprintf("ML: %s\n", @address)
      @added_members.each {|address|
	header << _("New Member: %s\n", obfuscate_address(address))
      }
      header << "\n"
      header
    end

    def member_added?
      @member_added_p
    end

    def nu_generate_footer (member_list_p = false)
      footer = "\n--\n" + "ML: #{@address}\n" + 
	_("Info: %s\n", @config.info_url)
      footer << unsubscribe_info if member_added?
      footer << "\n" + member_list if member_added? or member_list_p
      footer
    end

    def generate_footer (member_list_p = false)
      footer = "\n-- \n" + _("archive-> %s ", qwik_url) + "\nML-> #{@address}\n"
      footer << unsubscribe_info if member_added?
      footer << "\n" + member_list if member_added? or member_list_p
      footer
    end

    def qwik_url
      base = ""
      base = @key+".html" if defined?(@key) && @key
      "http://"+@config.domain+"/"+@name+"/"+base
    end

    def plain_text_body? (mail)
      (mail["Content-Type"] == "" or 
       %r!\btext/plain\b!i =~ mail["Content-Type"]) and
        (mail["Content-Transfer-Encoding"] == "" or
         /^[78]bit$/i =~ mail["Content-Transfer-Encoding"])
    end

    def rewrite_body (mail)
      header = generate_header if @member_added_p
      footer = generate_footer
      if mail.multipart?
	parts = mail.parts
	sub_mail = Mail.new
	sub_mail.read(parts.first)
	if sub_mail.content_type == "text/plain"
	  sub_mail.body = header + sub_mail.body if header
	  sub_mail.body = sub_mail.body + footer
	end
	parts[0] = sub_mail.to_s
	mail.body = Mail.join_parts(parts, mail.boundary)
      elsif plain_text_body?(mail)
	mail.body = header + mail.body if header
	mail.body += footer
      else
	mail.body
      end
    end

    def remove_alertedp_file
      File.safe_unlink(@alertedp_file)
    end

    def _submit(mail)
      qwik_post(mail)
      _org_submit(mail)
    end

    def qwik_post(mail, test=false)
      site = QwikSite.new(self.name, test)
      site.post(mail)
      @key = site.key # @key will be used at qwik_url
      @logger.log "[#{@name}]: QwikPost: #{@key}"
    end

    def _org_submit (mail)
      inc_count
      save_charset
      remove_alertedp_file

      subject = Mail.rewrite_subject(mail["Subject"], @short_name, @count)
      body = rewrite_body(mail)
      header = []
      mail.each_field {|key, value|
	k = key.downcase
	next if k == "subject" or k == "reply-to"
	header.push([key, value])
      }
      header.push(["Subject",	subject],
		  ["Reply-To",	@address],
		  ["X-Mail-Count",@count])
      header.concat(quickml_fields)
      Mail.send_mail(@config.smtp_host, @config.smtp_port, @logger,
		     :mail_from => @return_address, 
		     :recipients => @active_members,
		     :header => header,
		     :body => body)
    end

    def quickml_fields
      [
	["Precedence",   "bulk"],
        ["X-ML-Address", @address],
	["X-ML-Name",	 @name],
	["X-ML-Info",	 @config.info_url],
	["X-QuickML",	 "true"]
      ]
    end

    def remove_error_member (address)
      @error_members.delete(address)
    end

    def error_count (address)
      if @error_members.include?(address)
	@error_members[address].count
      else
	0
      end
    end

    def allowable_error_interval? (time)
      now  = Time.now
      past = now - @config.allowable_error_interval
      past < time && time <= now
    end

    def inc_error_count (address)
      unless @error_members.include?(address)
	@error_members[address] = ErrorInfo.new
      end
      unless allowable_error_interval?(@error_members[address].last_error_time)
	@error_members[address].inc_count
      end
      @error_members[address].count
    end

    def reset_error_member (address)
      return unless @error_members.include?(address)
      @error_members.delete(address)
      @logger.log "[#{@name}]: ResetError: #{address}"
      save_member_file
    end

    def content_type
      if @message_charset
        @config.content_type + "; charset=#{@message_charset}" 
      else
        @config.content_type
      end
    end

  end
end

if $0 == __FILE__
  require "qwik/test-module-quickml"
  require "qwik/ml-site"
  $debug = true
end

if $debug
  class TC_MLQuickML < Test::Unit::TestCase
    include TestQuickMLModule

    def test_dummy
    end

    def nutest_vaild_name
      assert_equal(0, QuickML::QuickML.valid_name?("t"))
      assert_equal(0, QuickML::QuickML.valid_name?("t-t"))
      assert_equal(nil, QuickML::QuickML.valid_name?("t_t"))
      assert_equal(nil, QuickML::QuickML.valid_name?("t.t"))
      assert_equal(0, QuickML::QuickML.valid_name?("test@example.com"))
      assert_equal(nil, QuickML::QuickML.valid_name?("test@qwik@jp"))
      assert_equal(0, QuickML::QuickML.valid_name?("test"))
      assert_equal(nil, QuickML::QuickML.valid_name?("te.st"))
    end

    def test_all
      #qp "hi"
      #qp "hi", @ml_config
      qml = QuickML::QuickML.new(@ml_config, "test@example.com")
      #qp "hi"
      #return

      # test generate_fotter
      t_make_public(QuickML::QuickML, "generate_footer")
      assert_equal("\n-- \narchive-> http://qwik.jp/test/ \nML-> test@example.com\n", qml.generate_footer)

      # test qwik_url
      t_make_public(QuickML::QuickML, "qwik_url")
      assert_equal("http://qwik.jp/test/", qml.qwik_url)
      qml.instance_eval {
	@key = "t"
      }
      assert_equal("http://qwik.jp/test/t.html", qml.qwik_url)

      # test last_article_time
      t_make_public(QuickML::QuickML, "last_article_time")
      assert_instance_of(Time, qml.last_article_time)
    end
  end
end
