#!/usr/bin/env ruby

# Before running this script be sure to:
# * create your space at Assembla.com: http://www.assembla.com/spaces/new
# * invite trac users to assembla, space Team tab
# * add tickets and milestones tools: Admin tab, Tools sub tab
# * change below settings and user map

SETTINGS = {
  # set your username and password used for Assembla.com
  # be sure you are the owner of below space
  :username => 'username',
  :password => 'password',

  # Enter below url of your space admin tab
  # It should be something like this: http://www.assembla.com/spaces/<uuid>/edit
  :space_url => 'http://bk.host.com/spaces/aE7ibEWSar3kPbakgWavsC/edit',

  # Location of your trac instance
  :trac_path => '/opt/beta/trac/breakout',

  # Ticket numbers greater then below number will be imported
  :trac_ticket_start => 0,

  # use ssl ?
  :ssl => true
}

# Map your trac users to Assembla accounts
# by default script will match users by username
USER_MAP = {
  # :trac_user1 => :space_user1,
  # :trac_user2 => :space_user2,
  # :trac_user3 => :space_user3
}

# End of user configuration settings
################################################################################

# gem install mime-types sqlite3-ruby xml-simple

# If you make some changes to this script and you think it will be useful for others,
# please submit a patch to our forum
# http://forum.assembla.com

# TODO
# * use threads to import faster many tickets
# * first select all usernames and show to user which ones has no assembla record, ask him to improve mapping?
# * Show how many ticket left to import [125 of 552] or percent

require "ostruct"
require 'rubygems'
require "cgi"
require 'mime/types'
require 'date'
require "uri"
require 'net/https'
require 'sqlite3'
require 'xmlsimple'

class Trac2Assembla
  attr_accessor :settings, :user_map, :trac_db, :ids_map
  attr_accessor :current_ticket, :ticket_submited
  # map priorities to breakout
  attr_accessor  :priorities_map, :priorities

  def initialize(settings, user_map)
    self.settings = settings
    self.user_map = { }
    user_map.each { |k, v| self.user_map[k.to_s.downcase] = v.to_s.downcase }

    self.ids_map = { :components => { }, :milestones => { }, :tickets => { }, :users => { } }
    uri = URI.parse(api_url)
    port = uri.port == 80 && settings[:ssl] ? 443 : uri.port
    @http = Net::HTTP.new(uri.host, port)
    @http.use_ssl = true if settings[:ssl]
    extract_api_url
    self.priorities = %w{1 2 3 4 5}
    self.priorities_map = {}
  end

  def import
    tools = create_array get_list("#{@api_path}/tools"), 'spaces-tool'
    tool_ids = tools.map { |el| el['tool-id']['content'].to_i }

    if !(tool_ids.include?(13) && tool_ids.include?(9))
      puts "Error: Please create Milestone and Ticket tools for your space."
      exit
    end

    puts "= Retrieving your space users..."
    users = create_array get_list("#{@api_path}/users"), 'user'
    users.each {|el| ids_map[:users][el['login_name'].downcase] = el['id'] }
    # TODO validate anonymous map

    puts "= Opening Trac db"
    self.trac_db = SQLite3::Database.new(File.join(settings[:trac_path], 'db', 'trac.db'))
    trac_db.results_as_hash = true

    import_components
    import_milestones
    import_tickets
  end

  def import_tickets
    puts "= Importing tickets"

    trac_db.execute("SELECT name, value FROM enum where type='priority' ORDER BY value") do |row|
      priorities_map[row['name']] = row['value'].to_s
    end

    trac_db.execute("SELECT * FROM ticket where id > #{settings[:trac_ticket_start]} ORDER BY id") do |row|
      self.ticket_submited = false

      print "== Ticket ##{row['id']} "
      ticket = { :ticket => {
          :number => row['id'],
          :summary => row['summary'],
          :description => convert_trac_wiki_to_textile(row['description']),
          :assigned_to_id => user_id(row['owner']),
          :acts_as_user_id => user_id(row['reporter']),
          :milestone_id => ids_map[:milestones][row['milestone'].downcase],
          :component_id => ids_map[:components][row['component'].downcase],
          :status => 0, # new
          :priority => ticket_priority(row['priority']),
          :created_on => trac_time_to_time(row['time'], Time.now),
          :updated_at => trac_time_to_time(row['changetime'], Time.now),
          :skip_alerts => true,
          # :severity_id => row['type'],
          # :version_id   => row['version'],
          # :resolution => row['resolution'],
        }
      }

      self.current_ticket = ticket

      puts "ok"
      import_ticket_changes(row['id'])
      # if was not submitted
      submit_ticket
      import_ticket_attachments(row['id'])
    end
  end

  def submit_ticket
    unless ticket_submited
      result = post_form("#{@api_path}/tickets", current_ticket)
      ids_map[:tickets][current_ticket[:ticket][:number]] = result['id']['content']
      self.ticket_submited = true
    end
  end

  def import_ticket_attachments(ticket_id)
    print " * Attachments "
    trac_db.execute("SELECT * FROM attachment WHERE type = 'ticket' AND id = #{ticket_id}") do |row|
      file_path = File.join(settings[:trac_path], 'attachments', 'ticket', ticket_id, CGI::escape(row['filename']).gsub('+', '%20'))

      File.open(file_path) do |f|
        # TODO Add some tag to documents
        params = {
          'document[file]' => f,
          'document[name]' => row['filename'],
          'document[skip_alerts]' => true,
          'document[description]' => row['description'],
          'document[created_at]' => trac_time_to_time(row['time']),
          'document[updated_at]' => trac_time_to_time(row['time']),
          'document[created_by]' => user_id(row['author']),
          'document[updated_by]' => user_id(row['author']),
          'document[attachable_id]' => ids_map[:tickets][ticket_id.to_s],
          'document[attachable_type]' => 'Ticket'
        }

        print '.'

        begin
          upload_file params
        rescue RemoteError =>e
          # TODO count failing requests
          p e
          p params
        end
      end
    end

    puts "ok"
  end

  def import_ticket_changes(ticket_id)
    print " * Changes "
    time_val = '0'
    changes = { }
    row = { }

    trac_db.execute("SELECT * FROM ticket_change WHERE ticket = #{ticket_id} ORDER BY time") do |row|
      if time_val != row['time']
        if time_val != '0'
          print '.'
          submit_ticket_change ticket_id, row['author'], time_val, changes
          changes = { }
        end

        time_val = row['time']
      end

      changes[row['field'].to_sym] = { :old_value => row['oldvalue'], :new_value => row['newvalue']}
    end

    # submit last changes if not empty
    submit_ticket_change( ticket_id, row['author'], time_val, changes) if time_val != '0'
    puts 'ok'
  end

  def submit_ticket_change(ticket_id, author, time, changes)
    # Get original values from first changes
    unless ticket_submited
      t = current_ticket[:ticket]
      if changes[:component]
        t[:component_id] = changes[:component][:old_value].blank? ? '' : ids_map[:components][changes[:component][:old_value].downcase]
      end

      if changes[:milestone]
        t[:milestone_id] = changes[:milestone][:old_value].blank? ? '' : ids_map[:milestones][changes[:milestone][:old_value].downcase]
      end

      t[:assigned_to_id] = user_id(changes[:owner][:old_value]) if changes[:owner]
      t[:summary] = changes[:summary][:old_value] if changes[:summary]
      t[:description] = changes[:description][:old_value] if changes[:description]
      t[:priority] = ticket_priority(changes[:priority][:old_value]) if changes[:priority]
      submit_ticket
    end

    ticket = {
      :number => ticket_id,
      :updated_at => trac_time_to_time(time, Time.now),
      :skip_alerts => true,
      :acts_as_user_id => user_id(author)
    }

    if changes[:component]
      ticket[:component_id] = (changes[:component][:new_value].blank? ? '' :
                               ids_map[:components][changes[:component][:new_value].downcase])
    end

    if changes[:milestone]
      ticket[:milestone_id] = (changes[:milestone][:new_value].blank? ? '' :
                            ids_map[:milestones][changes[:milestone][:new_value].downcase])
    end

    ticket[:summary] = changes[:summary][:new_value] if changes[:summary]
    ticket[:description] = changes[:description][:new_value] if changes[:description]

    # Status check
    case changes[:status][:new_value]
    when 'new'
      ticket[:status] = 0 if changes[:status][:old_value] == 'assigned' # new
    when 'assigned'
      ticket[:status] = 1 # accepted
    when 'reopened'
      ticket[:status] = 0 # new
    end if changes[:status]

    # Resolution check
    case changes[:resolution][:new_value]
    when "fixed"
      ticket[:status] = 3 # closed - fixed
    when /wontfix|duplicate|invalid|worksforme/
      ticket[:status] = 2 # closed - invalid
    end if changes[:resolution]

    if changes[:priority]
      ticket[:priority] = ticket_priority changes[:priority][:new_value]
    end

    if changes[:owner]
      ticket[:assigned_to_id] = user_id(changes[:owner][:new_value])
    end

    params = { :ticket => ticket }

    if changes[:comment] && !changes[:comment][:new_value].blank?
      ticket[:user_comment] = convert_trac_wiki_to_textile(changes[:comment][:new_value])
    end

    begin
      put_form "#{@api_path}/tickets/#{ticket_id}", params
    rescue RemoteError => e
      # TODO count failing requests
      puts e.message
      puts e.body
      p changes
    end
  end

  def import_components
    puts "== Importing components"
    components = create_array get_list("#{@api_path}/tickets/components"), 'component'

    components.each do |el|
      ids_map[:components][el['name'].downcase] = el['id']['content']
    end

    trac_db.execute("SELECT * FROM component") do |row|
      # not blank
      next if row['name'] =~ /^\s*$/
      print "Creating component #{row['name']}..."

      if ids_map[:components][row['name'].downcase]
        print "exists\n"
      else
        result = create_component(row['name'])
        ids_map[:components][row['name'].downcase] = result['id']['content']
        print "ok\n"
      end
    end
  end

  def import_milestones
    puts "== Importing milestones"
    milestones = create_array get_list("#{@api_path}/milestones"), 'milestone'

    milestones.each do |el|
      ids_map[:milestones][el['title'].downcase] = el['id']['content']
    end

    trac_db.execute("SELECT * FROM milestone") do |row|
      print "Creating Milestone #{row['name']}..."

      if ids_map[:milestones][row['name'].downcase]
        print "exists\n"
      else
        result = post_form("#{@api_path}/milestones", :milestone => {
                             :title => row['name'],
                             :description => row['description'],
                             :due_date => trac_time_to_date(row['due'], Date.today),
                             :skip_alerts => true,
                             :is_completed => (row['completed'].to_i != 0),
                             :completed_date => trac_time_to_date(row['completed'])
                           }
                           )
        ids_map[:milestones][row['name'].downcase] = result['id']['content']
        print "ok\n"
      end
    end
  end

  def user_id(username)
    return '' if username.blank?
    u = username.downcase
    u = user_map[u] if user_map.has_key?(u)

    if ids_map[:users].has_key?(u)
      ids_map[:users][u]
    else
      print "Anonymous for #{u}."
      'bgfq4qA1Gr2QjIaaaHk9wZ' # Anonymous ID
    end
  end

  # trac_status as integer
  def ticket_status(trac_status)
    status_map = { 1 => 0, 2 => 1, 3 => 0, 4 => 3}

    if status_map.has_key?(trac_status)
      return status_map[trac_status]
    else
      0  # new
    end
  end

  def ticket_priority(trac_priority)
    priority = priorities_map[trac_priority]
    priorities.include?(priority) ? priority : 3
  end

  def trac_time_to_time(time, default = nil)
    return default if time.to_i == 0
    Time.at time.to_i
  end

  def trac_time_to_date(time, default = nil)
    return default if time.to_i == 0
    t = Time.at time.to_i
    Date.new(t.year, t.month, t.day)
  end

  def create_component(name)
    post_form("#{@api_path}/tickets/create_component", :component_name => name)
  end

  def get_list(api_uri)
    request = http_method :get, api_uri
    response = @http.request(request)
    parse_response api_uri, response
  end

  def post_form(api_uri, form_data)
    request = http_method :post, api_uri
    request.form_data = rails_values form_data
    response = @http.request(request)

    parse_response api_uri, response
  end

  def put_form(api_uri, form_data)
    request = http_method :put, api_uri
    request.form_data = rails_values form_data
    response = @http.request(request)

    parse_response api_uri, response
  end

  def upload_file(params)
    api_uri = "#{@api_path}/documents"
    mp = MultipartPost.new
    query, headers = mp.prepare_query(params)
    request = http_method :post, api_uri, headers
    request.body = query
    response = @http.request(request)

    parse_response api_uri, response
  end

  def http_method(method, api_uri, headers = { })
    case method
    when :get
      klass = Net::HTTP::Get
    when :post
      klass = Net::HTTP::Post
    when :put
      klass = Net::HTTP::Put
    else
      raise 'Invalid method'
    end

    request = klass.new(api_uri, {'Accept' => 'application/xml'}.update(headers))
    request.basic_auth settings[:username], settings[:password]
    request
  end

  def create_array xml_repr, key
    if xml_repr[key].is_a?(Array)
      xml_repr[key]
    elsif xml_repr[key]
      [xml_repr[key]]
    else
      []
    end
  end

  def parse_response(api_uri, response)
    if %w{200 201}.include?(response.code)
      XmlSimple.xml_in(response.body, { 'ForceArray' => false })
    else
      puts response.body
      raise RemoteError.new("Error #{api_uri}", response.body)
    end
  end

  def rails_values params = { }
    rez = { }

    params.each do |k, v|
      if v.is_a?(Hash)
        rez.update(hash_param(k, v))
      elsif v.is_a?(Time)
        rez[k] = v.utc.strftime("%Y-%m-%d %H:%M:%S")
      elsif v.is_a?(Date)
        rez[k] = v.strftime("%Y-%m-%d")
      else
        rez[k] = v
      end
    end

    rez
  end

  def convert_trac_wiki_to_textile(str)
    # bold italic
    rez = str.gsub /'''''(.*)'''''/, '*_\1_*'
    # bold
    rez.gsub! /'''(.*)'''/, '*\1*'
    # italic
    rez.gsub! /''(.*)''/, '_\1_'
    # underline
    rez.gsub! /__(.*)__/, '+\1+'
    # monospace
    rez.gsub! /\{\{\{(.*)\}\}\}/m, '<pre><code>\1</code></pre>'
    # strike through
    rez.gsub! /~~(.*)~~/ , '-\1-'
    # subscript
    rez.gsub! /,,(.*),,/, '~\1~'
    # headings
    rez.gsub! /(={1,6}) (.*) (={1,6})/ do |m| "h#{$1.length}. \2" end
    # break
    rez.gsub! /\[\[BR\]\]/, "\n"
    # urllink
    rez.gsub! /\[(https?):\/\/([^ ]*) ?(.*)\]/, '"\3":\1://\2'
    rez
  end

  def hash_param key, value
    rez = { }

    value.each do |k, v|
      rez["#{key}[#{k}]"] = v
    end

    rez
  end

  def api_url
    @api_url ||= extract_api_url
  end

  def extract_api_url
    u = URI.parse(settings[:space_url])
    u.path = u.path.split(/\//)[0..2].join('/')
    @api_path = u.path
    u.to_s
  end
end

class Param
  attr_accessor :k, :v
  def initialize( k, v )
    @k = k
    @v = v
  end

  def to_multipart
    #return "Content-Disposition: form-data; name=\"#{CGI::escape(k)}\"\r\n\r\n#{v}\r\n"
    # Don't escape mine...
    return "Content-Disposition: form-data; name=\"#{k}\"\r\n\r\n#{v}\r\n"
  end
end

class FileParam
  attr_accessor :k, :filename, :content
  def initialize( k, filename, content )
    @k = k
    @filename = filename
    @content = content
  end

  def to_multipart
    #return "Content-Disposition: form-data; name=\"#{CGI::escape(k)}\"; filename=\"#{filename}\"\r\n" + "Content-Transfer-Encoding: binary\r\n" + "Content-Type: #{MIME::Types.type_for(@filename)}\r\n\r\n" + content + "\r\n "
    # Don't escape mine
    return "Content-Disposition: form-data; name=\"#{k}\"; filename=\"#{filename}\"\r\n" + "Content-Transfer-Encoding: binary\r\n" + "Content-Type: #{MIME::Types.type_for(@filename)}\r\n\r\n" + content + "\r\n"
  end
end

class MultipartPost
  BOUNDARY = 'tarsiers-rule0000'
  HEADER = {"Content-type" => "multipart/form-data, boundary=" + BOUNDARY + " "}

  def prepare_query (params)
    fp = []
    params.each {|k,v|
      if v.respond_to?(:read)
        fp.push(FileParam.new(k, v.path, v.read))
      else
        fp.push(Param.new(k,v))
      end
    }
    query = fp.collect {|p| "--" + BOUNDARY + "\r\n" + p.to_multipart }.join("") + "--" + BOUNDARY + "--"
    return query, HEADER
  end
end

class RemoteError < StandardError
  attr_accessor :message, :body

  def initialize(message, body)
    self.message = message
    self.body = body.to_s
  end
end

# Action Support feature
class String #:nodoc:
  def blank?
    self !~ /\S/
  end
end

class NilClass #:nodoc:
  def blank?
    true
  end
end

$stdout.sync = true

i = Trac2Assembla.new(SETTINGS, USER_MAP)
i.import

