require "time"

module L2meter
  class Emitter
    attr_reader :configuration

    def initialize(configuration: Configuration.new)
      @configuration = configuration
      @buffer = {}
      @autoflush = true
      @contexts = []
      @outputs = []
    end

    def log(*args)
      merge! *current_contexts, *args

      if block_given?
        wrap &proc
      else
        write
      end
    end

    def with_elapsed(start_time = Time.now, &block)
      context(elapsed_context(start_time), &block)
    end

    def with_output(output)
      @outputs.push output
      yield
    ensure
      @outputs.pop
    end

    def silence
      with_output(NullObject.new, &proc)
    end

    def silence!
      @outputs.push NullObject.new
    end

    def unsilence!
      @outputs.pop
    end

    def measure(metric, value, unit: nil)
      log_with_prefix :measure, metric, value, unit: unit
    end

    def sample(metric, value, unit: nil)
      log_with_prefix :sample, metric, value, unit: unit
    end

    def count(metric, value = 1)
      log_with_prefix :count, metric, value
    end

    def unique(metric, value)
      log_with_prefix :unique, metric, value
    end

    def context(*context_data)
      return clone_with_context(context_data) unless block_given?
      push_context context_data
      yield
    ensure
      context_data.length.times { @contexts.pop } if block_given?
    end

    def clone
      cloned_contexts = @contexts.clone
      self.class.new(configuration: configuration).instance_eval do
        @contexts = cloned_contexts
        self
      end
    end

    def batch
      @autoflush = false
      yield
    ensure
      @autoflush = true
      fire!
    end

    def merge!(*args)
      @buffer.merge! format_keys(unwrap(args))
    end

    def fire!
      tokens = @buffer.map do |key, value|
        next if value.nil?
        key = format_key(key)
        value == true ? key : "#{key}=#{format_value(value)}"
      end.compact

      tokens.sort! if configuration.sort?

      output_queue.last.print [*tokens].join(" ") + "\n" if tokens.any?
    ensure
      @buffer.clear
    end

    protected

    def push_context(context_data)
      @contexts.concat context_data
    end

    private

    def unwrap(args)
      args.each_with_object({}) do |context, result|
        next if context.nil?
        context = Hash[context, true] unless Hash === context
        result.merge! context
      end
    end

    def format_float(value, unit: nil)
      "%.#{configuration.float_precision}f#{unit}" % value
    end

    def clone_with_context(context)
      clone.tap do |emitter|
        emitter.push_context context
      end
    end

    def current_contexts
      contexts_queue.map do |context|
        context = context.call if context.respond_to?(:call)
        context
      end
    end

    def format_value(value)
      case value
      when /[^\w,.:@-]/
        value.strip.gsub(/\s+/, " ").inspect
      when String
        value.to_s
      when Float
        format_float(value)
      when Time
        value.iso8601
      when Proc
        format_value(value.call)
      when Hash
        format_value(value.inspect)
      when Array
        value.map(&method(:format_value)).join(?,)
      else
        format_value(value.to_s)
      end
    end

    def format_key(key)
      configuration.key_formatter.call(key)
    end

    def format_keys(hash)
      hash.each_with_object({}) { |(k, v), a| a[format_key(k)] = v }
    end

    def write(params = nil)
      merge! params
      fire! if @autoflush
    end

    def log_with_prefix(method, key, value, unit: nil)
      key = [configuration.prefix, key, unit].compact * ?.
      log Hash["#{method}##{key}", value]
    end

    def wrap
      start_time = Time.now
      params = @buffer.clone
      write at: :start
      result = exception = nil

      begin
        result = yield
        merge! params, at: :finish
      rescue Object => exception
        merge! params, at: :exception, exception: exception.class, message: exception.message
      end

      write elapsed_context(start_time)

      raise exception if exception

      result
    end

    def contexts_queue
      [configuration.context, source_context, *@contexts].compact
    end

    def output_queue
      [configuration.output, *@outputs].compact
    end

    def source_context
      { source: configuration.source }
    end

    def elapsed_context(since = Time.now)
      { elapsed: -> { elapsed_value(since) }}
    end

    def elapsed_value(since)
      format_float(Time.now - since, unit: ?s)
    end
  end
end