#!/usr/bin/env ruby

$: << File.join(File.dirname(__FILE__), '..', 'lib')
require 'benchmark'
require 'moneta'
require 'fileutils'

class String
  def random(n)
    (1..n).map { self[rand(size),1] }.join
  end
end

class Array
  def sum
    inject(0, &:+)
  end

  def mean
    sum / size
  end

  def stddev
    m = mean
    Math.sqrt(map {|s| (s - m) ** 2 }.mean)
  end
end

class MonetaBenchmarks
  DIR = __FILE__ + '.tmp'

  STORES = {
    # SDBM accepts only very short key/value pairs (1k for both)
    # SDBM: { file: "#{DIR}/sdbm" },
    # YAML is too slow
    # YAML: { file: "#{DIR}/yaml" },
    ActiveRecord: { table: 'activerecord', connection: { adapter: (defined?(JRUBY_VERSION) ? 'jdbcmysql' : 'mysql2'), username: 'root', database: 'moneta' } },
    Cassandra: {},
    Client: {},
    Couch: {},
    DBM: { file: "#{DIR}/dbm" },
    DataMapper: { setup: 'mysql://root:@localhost/moneta', table: 'datamapper' },
    Daybreak: { file: "#{DIR}/daybreak" },
    File: { dir: "#{DIR}/file" },
    GDBM: { file: "#{DIR}/gdbm" },
    HBase: {},
    HashFile: { dir: "#{DIR}/hashfile" },
    KyotoCabinet: { file: "#{DIR}/kyotocabinet.kch" },
    LRUHash: {},
    LevelDB: { dir: "#{DIR}/leveldb" },
    LocalMemCache: { file: "#{DIR}/lmc" },
    LMDB: { dir: "#{DIR}/lmdb" },
    MemcachedDalli: {},
    MemcachedNative: {},
    Memory: {},
    MongoMoped: {},
    MongoOfficial: {},
    PStore: { file: "#{DIR}/pstore" },
    Redis: {},
    RestClient: { url: 'http://localhost:8808/' },
    Riak: {},
    Sequel: { table: 'sequel',
      db: (defined?(JRUBY_VERSION) ?
              'jdbc:mysql://localhost/moneta?user=root' :
              'mysql2://root:@localhost/moneta') },
    Sqlite: { file: ':memory:' },
    TDB: { file: "#{DIR}/tdb" },
    TokyoCabinet: { file: "#{DIR}/tokyocabinet" },
    TokyoTyrant: {},
  }

  CONFIGS = {
    uniform_small: {
      runs: 3,
      keys: 1000,
      min_key_len: 1,
      max_key_len: 32,
      key_dist: :uniform,
      min_val_len: 0,
      max_val_len: 256,
      val_dist: :uniform
    },
    uniform_medium: {
      runs: 3,
      keys: 1000,
      min_key_len: 3,
      max_key_len: 128,
      key_dist: :uniform,
      min_val_len: 0,
      max_val_len: 1024,
      val_dist: :uniform
    },
    uniform_large: {
      runs: 3,
      keys: 100,
      min_key_len: 3,
      max_key_len: 128,
      key_dist: :uniform,
      min_val_len: 0,
      max_val_len: 10240,
      val_dist: :uniform
    },
    normal_small: {
      runs: 3,
      keys: 1000,
      min_key_len: 1,
      max_key_len: 32,
      key_dist: :normal,
      min_val_len: 0,
      max_val_len: 256,
      val_dist: :normal
    },
    normal_medium: {
      runs: 3,
      keys: 1000,
      min_key_len: 3,
      max_key_len: 128,
      key_dist: :normal,
      min_val_len: 0,
      max_val_len: 1024,
      val_dist: :normal
    },
    normal_large: {
      runs: 3,
      keys: 100,
      min_key_len: 3,
      max_key_len: 128,
      key_dist: :normal,
      min_val_len: 0,
      max_val_len: 10240,
      val_dist: :normal
    },
  }

  DICT = 'ABCDEFGHIJKLNOPQRSTUVWXYZabcdefghijklnopqrstuvwxyz123456789'.freeze
  HEADER = "\n                         Minimum  Maximum    Total     Mean   Stddev    Ops/s"
  SEPARATOR = '=' * 77

  module Rand
    extend self

    def normal_rand(mean, stddev)
      # Box-Muller transform
      theta = 2 * Math::PI * (rand(1e10) / 1e10)
      scale = stddev * Math.sqrt(-2 * Math.log(1 - (rand(1e10) / 1e10)))
      [mean + scale * Math.cos(theta),
       mean + scale * Math.sin(theta)]
    end

    def uniform(min, max)
      rand(max - min) + min
    end

    def normal(min, max)
      mean = (min + max) / 2
      stddev = (max - min) / 4
      loop do
        val = normal_rand(mean, stddev)
        return val.first if val.first >= min && val.first <= max
        return val.last if val.last >= min && val.last <= max
      end
    end
  end

  def parallel(&block)
    if defined?(JRUBY_VERSION)
      Thread.new(&block)
    else
      Process.fork(&block)
    end
  end

  def write_histogram(file, sizes)
    min = sizes.min
    delta = sizes.max - min
    histogram = []
    sizes.each do |s|
      s = 10 * (s - min) / delta
      histogram[s] ||= 0
      histogram[s] += 1
    end
    File.open(file, 'w') do |f|
      histogram.each_with_index { |n,i| f.puts "#{i*delta/10+min} #{n}" }
    end
  end

  def start_servers
    parallel do
      begin
        Moneta::Server.new(Moneta.new(:Memory)).run
      rescue Exception => ex
        puts "\e[31mFailed to start Moneta server - #{ex.message}\e[0m"
      end
    end

    parallel do
      begin
        require 'rack'
        require 'webrick'
        require 'rack/moneta_rest'

        # Keep webrick quiet
        ::WEBrick::HTTPServer.class_eval do
          def access_log(config, req, res); end
        end
        ::WEBrick::BasicLog.class_eval do
          def log(level, data); end
        end

        Rack::Server.start(app: Rack::Builder.app do
                             use Rack::Lint
                             run Rack::MonetaRest.new(store: :Memory)
                           end,
                           environment: :none,
                           server: :webrick,
                           Port: 8808)
      rescue Exception => ex
        puts "\e[31mFailed to start Rack server - #{ex.message}\e[0m"
      end
    end

    sleep 1 # Wait for servers
  end

  def test_stores
    STORES.each do |name, options|
      begin
        if name == :DataMapper
          begin
            require 'dm-core'
            DataMapper.setup(:default, adapter: :in_memory)
          rescue LoadError => ex
            puts "\e[31mFailed to load DataMapper - #{ex.message}\e[0m"
          end
        elsif name == :Riak
          require 'riak'
          Riak.disable_list_keys_warnings = true
        end

        cache = Moneta.new(name, options.dup)
        cache['test'] = 'test'
      rescue Exception => ex
        puts "\e[31m#{name} not benchmarked - #{ex.message}\e[0m"
        STORES.delete(name)
      ensure
        (cache.close rescue nil) if cache
      end
    end
  end

  def generate_data
    until @data.size == @config[:keys]
      key = DICT.random(Rand.send(@config[:key_dist], @config[:min_key_len], @config[:max_key_len]))
      @data[key] = DICT.random(Rand.send(@config[:val_dist], @config[:min_val_len], @config[:max_val_len]))
    end

    key_lens, val_lens = @data.keys.map(&:size), @data.values.map(&:size)
    @data = @data.to_a

    write_histogram("#{DIR}/key.histogram", key_lens)
    write_histogram("#{DIR}/value.histogram", val_lens)

    puts "\n\e[1m\e[34m#{SEPARATOR}\n\e[34mComputing keys and values...\n\e[34m#{SEPARATOR}\e[0m"
    puts %{                         Minimum  Maximum    Total     Mean   Stddev}
    puts 'Key Length              % 8d % 8d % 8d % 8d % 8d' % [key_lens.min, key_lens.max, key_lens.sum, key_lens.mean, key_lens.stddev]
    puts 'Value Length            % 8d % 8d % 8d % 8d % 8d' % [val_lens.min, val_lens.max, val_lens.sum, val_lens.mean, val_lens.stddev]
  end

  def print_config
    puts "\e[1m\e[36m#{SEPARATOR}\n\e[36mConfig #{@config_name}\n\e[36m#{SEPARATOR}\e[0m"
    @config.each do |k,v|
      puts '%-16s = %-10s' % [k,v]
    end
  end

  def print_store_stats(name)
    puts HEADER
    [:write, :read, :sum].each do |i|
      ops = (1000 * @config[:runs] * @data.size) / @stats[name][i].sum
      line = '%-17.17s %-5s % 8d % 8d % 8d % 8d % 8d % 8d' %
        [name, i, @stats[name][i].min, @stats[name][i].max, @stats[name][i].sum,
         @stats[name][i].mean, @stats[name][i].stddev, ops]
      @summary << [-ops, line << "\n"] if i == :sum
      puts line
    end

    errors = @stats[name][:error].sum
    if errors > 0
      puts "\e[31m%-23.23s % 8d % 8d % 8d % 8d\e[0m" %
        ['Read errors', @stats[name][:error].min, @stats[name][:error].max, errors, errors / @config[:runs]]
    else
      puts "\e[32mNo read errors"
    end
  end

  def benchmark_store(name, options)
    puts "\n\e[1m\e[34m#{SEPARATOR}\n\e[34m#{name}\n\e[34m#{SEPARATOR}\e[0m"

    store = Moneta.new(name, options.dup)

    @stats[name] = {
      write: [],
      read: [],
      sum: [],
      error: []
    }

    %w(Rehearse Measure).each do |type|
      state = ''
      print "%s [%#{2 * @config[:runs]}s] " % [type, state]

      @config[:runs].times do |run|
        store.clear

        @data.shuffle!
        m1 = Benchmark.measure do
          @data.each {|k,v| store[k] = v }
        end

        print "%s[%-#{2 * @config[:runs]}s] " % ["\b" * (2 * @config[:runs] + 3), state << 'W']

        @data.shuffle!
        error = 0
        m2 = Benchmark.measure do
          @data.each do |k, v|
            error += 1 if v != store[k]
          end
        end

        print "%s[%-#{2 * @config[:runs]}s] " % ["\b" * (2 * @config[:runs] + 3), state << 'R']

        if type == 'Measure'
          @stats[name][:write] << m1.real * 1000
          @stats[name][:error] << error
          @stats[name][:read] << m2.real * 1000
          @stats[name][:sum] << (m1.real + m2.real) * 1000
        end
      end
    end

    print_store_stats(name)
  rescue StandardError => ex
    puts "\n\e[31mFailed to benchmark #{name} - #{ex.message}\e[0m\n"
  ensure
    store.close if store
  end

  def run_benchmarks
    STORES.each do |name, options|
      benchmark_store(name, options)
      sleep 1
    end
  end

  def print_summary
    puts "\n\e[1m\e[36m#{SEPARATOR}\n\e[36mSummary #{@config_name}: #{@config[:runs]} runs, #{@data.size} keys\n\e[36m#{SEPARATOR}\e[0m#{HEADER}\n"
    @summary.sort_by(&:first).each do |entry|
      puts entry.last
    end
  end

  def initialize(args)
    @config_name = args.size == 1 ? args.first.to_sym : :uniform_medium
    unless @config = CONFIGS[@config_name]
      puts "Configuration #{@config_name} not found"
      exit
    end

    # Disable jruby stdout pollution by memcached
    if defined?(JRUBY_VERSION)
      require 'java'
      properties = java.lang.System.getProperties();
      properties.put('net.spy.log.LoggerImpl', 'net.spy.memcached.compat.log.SunLogger');
      java.lang.System.setProperties(properties);
      java.util.logging.Logger.getLogger('').setLevel(java.util.logging.Level::OFF)
    end

    @stats, @data, @summary = {}, {}, []
  end

  def run
    FileUtils.rm_rf(DIR)
    FileUtils.mkpath(DIR)
    start_servers
    test_stores
    print_config
    generate_data
    run_benchmarks
    print_summary
    FileUtils.rm_rf(DIR)
  end
end

MonetaBenchmarks.new(ARGV).run
