# encoding: utf-8
require "logstash/namespace"
require "concurrent/atomic/atomic_boolean"
require "concurrent/map"

# This class is a Codec duck type
# Using Composition, it maps from a stream identity to
# a cloned codec instance via the same API as a Codec
# it implements the codec public API

module LogStash module Codecs class IdentityMapCodec
  # subclass of Exception, LS has more than limit (20000) active streams
  class IdentityMapUpperLimitException < Exception; end

  module EightyPercentWarning
    extend self
    def visit(imc)
      current_size, limit = imc.current_size_and_limit
      return if current_size < (limit * 0.8)
      imc.logger.warn("IdentityMapCodec has reached 80% capacity",
        :current_size => current_size, :upper_limit => limit)
    end
  end

  module UpperLimitReached
    extend self
    def visit(imc)
      current_size, limit = imc.current_size_and_limit
      return if current_size < limit
      # we hit the limit
      # try to clean out stale streams
      current_size, limit = imc.map_cleanup
      return if current_size < limit
      # we are still at the limit and all streams are in use
      imc.logger.error("IdentityMapCodec has reached 100% capacity",
          :current_size => current_size, :upper_limit => limit)
      raise IdentityMapUpperLimitException.new
    end
  end

  class PeriodicRunner
    def initialize(listener, interval, method_symbol)
      @listener, @interval = listener, interval
      @method_symbol = method_symbol
      @running = Concurrent::AtomicBoolean.new(true)
    end

    # The goal of periodic runner is to have a single thread to do clean up / auto flush
    # This method is expected to access by a single thread,
    # otherwise, multiple Thread.start could create more than one periodic runner
    def start
      return self unless running?
      return self if running? && !@thread.nil?

      @thread = Thread.start do
        class_name = @listener.class.name.split('::').last # IdentityMapCodec
        LogStash::Util.set_thread_name("#{class_name}##{@method_symbol}")
        @listener.logger.debug("Start periodic runner")

        while running? do
          sleep @interval
          break if !running?
          break if (listener = @listener).nil?
          listener.send(@method_symbol)
        end
      end
      self
    end

    def running?
      @running.true?
    end

    def stop
      return unless running?

      @listener.logger.debug("Stop periodic runner")
      @running.make_false
      while @thread&.alive?
        begin
          @thread.wakeup
        rescue ThreadError
          # thread might drop dead since the alive? check
        else
          @thread.join(0.1) # raises $! if there was any
        end
      end
      @listener = nil
    end
  end

  class NoopRunner
    attr_reader :start, :stop
    def running?() false; end
  end

  # A composite class to hold both the codec, the eviction_timeout and a last_used timestamp
  # instances of this Value Object are stored in the mapping hash
  class CodecValue
    attr_reader :codec
    attr_accessor :eviction_timeout, :auto_flush_timeout

    def initialize(codec)
      @codec = codec
    end
  end

  #maximum size of the mapping hash
  MAX_IDENTITIES = 20_000

  # time after which a stream is
  # considered stale
  # each time a stream is accessed
  # it is given a new timeout
  EVICT_TIMEOUT = 60 * 60 * 1 # 1 hour

  # time that the cleaner thread sleeps for
  # before it tries to clean out stale mappings
  CLEANER_INTERVAL = 60 * 5 # 5 minutes

  attr_reader :identity_map
  attr_accessor :base_codec, :cleaner, :auto_flusher

  def initialize(codec)
    @base_codec = codec
    @base_codecs = [codec]
    @identity_map = Concurrent::Hash.new &method(:codec_builder)
    @max_identities = MAX_IDENTITIES
    @evict_timeout = EVICT_TIMEOUT
    cleaner_interval(CLEANER_INTERVAL)
    if codec.respond_to?(:use_mapper_auto_flush) &&
        (@auto_flush_interval = codec.use_mapper_auto_flush)
      @auto_flusher = PeriodicRunner.new(self, 0.5, :auto_flush_mapped)
    else
      @auto_flusher = NoopRunner.new
    end

    @decode_block = lambda {|*| true }
    @eviction_block = nil
  end

  # ==============================================
  # Constructional/builder methods
  # chain this method off of new
  #
  # used to add a non-default maximum identities
  def max_identities(max)
    @max_identities = max.to_i
    self
  end

  # used to add a non-default evict timeout
  def evict_timeout(timeout)
    @evict_timeout = timeout.to_i
    self
  end

  # used to add  a non-default cleaner interval
  def cleaner_interval(interval)
    @cleaner.stop if @cleaner
    @cleaner = PeriodicRunner.new(self, interval.to_i, :map_cleanup)
    self
  end

  # used to add  a non-default eviction block
  def eviction_block(block)
    @eviction_block = block
    self
  end

  # end Constructional/builder methods
  # ==============================================

  # ==============================================
  # IdentityMapCodec API
  def evict(identity)
    # maybe called more than once
    if (compo = identity_map.delete(identity))
      compo.codec.auto_flush if compo.codec.respond_to?(:auto_flush)
    end
  end
  # end IdentityMapCodec API
  # ==============================================

  # ==============================================
  # Codec API
  def decode(data, identity = nil, &block)
    @decode_block = block if @decode_block != block
    stream_codec(identity).decode(data, &block)
  end

  def accept(listener)
    stream_codec(listener.path).accept(listener)
  end

  alias_method :<<, :decode

  def encode(event, identity = nil)
    stream_codec(identity).encode(event)
  end

  def flush(&block)
    all_codecs.each do |codec|
      #let ruby do its default args thing
      if block_given?
        codec.flush(&block)
      else
        if codec.respond_to?(:auto_flush)
          codec.auto_flush
        else
          #try this, no guarantees
          codec.flush
        end
      end
    end
  end

  def close()
    cleaner.stop
    auto_flusher.stop
    all_codecs.each(&:close)
  end
  # end Codec API
  # ==============================================

  def auto_flush_mapped
    if !identity_count.zero?
      nowf = Time.now.to_f
      identity_map.each do |identity, compo|
        next if compo.auto_flush_timeout.zero?
        next unless nowf > compo.auto_flush_timeout
        compo.codec.auto_flush
        # at eof (tail and read) no more lines for a while or ever
        # so reset compo.auto_flush_timeout
        compo.auto_flush_timeout = 0
      end
    end
  end

  def flush_mapped(listener)
    listener_has_path = listener.respond_to?(:path)
    identity_map.each do |identity, compo|
      listener.path = identity if listener_has_path
      compo.codec.auto_flush(listener)
    end
  end

  def all_codecs
    no_streams? ? @base_codecs : identity_map.values.map(&:codec)
  end

  def max_limit
    @max_identities
  end

  def identity_count
    identity_map.size
  end

  # support cleaning of stale stream/codecs
  # a stream is considered stale if it has not
  # been accessed in the last @evict_timeout
  # period (default 1 hour)
  def map_cleanup
    if !identity_count.zero?
      nowi = Time.now.to_i
      # delete_if is atomic
      # contents should not mutate during this call
      identity_map.delete_if do |identity, compo|
        if (flag = compo.eviction_timeout <= nowi)
          evict_flush(compo.codec)
        end
        flag
      end
    end
    current_size_and_limit
  end

  def evict_flush(codec)
    if codec.respond_to?(:auto_flush)
      codec.auto_flush
    else
      if (block = @eviction_block || @decode_block)
        codec.flush(&block)
      end
      # all else - can't do anything
    end
  end

  def current_size_and_limit
    [identity_count, max_limit]
  end

  def logger
    # we 'borrow' the codec's logger as we don't have our own
    @base_codec.logger
  end

  def codec_without_usage_update(identity)
    return base_codec if identity.nil? # mirror optimization in `stream_codec`
    find_codec_value(identity).codec
  end

  def eviction_timestamp_for(identity)
    find_codec_value(identity).eviction_timeout
  end

  private

  def stream_codec(identity)
    return base_codec if identity.nil?
    record_codec_usage(identity) # returns codec
  end

  def find_codec_value(identity)
    identity_map[identity]
  end

  # for nil stream this method is not called
  def record_codec_usage(identity)
    check_map_limits
    # only start the cleaner if streams are in use
    # continuous calls to start are OK if this codec method is accessed by a single thread
    cleaner.start
    auto_flusher.start
    compo = find_codec_value(identity)
    now = Time.now
    compo.eviction_timeout = eviction_timestamp(now)
    compo.auto_flush_timeout = auto_flush_timestamp(now)
    compo.codec
  end

  def auto_flush_timestamp(now = Time.now)
    now.to_f + @auto_flush_interval.to_f
  end

  def eviction_timestamp(now = Time.now)
    now.to_i + @evict_timeout
  end

  def check_map_limits
    UpperLimitReached.visit(self)
    EightyPercentWarning.visit(self)
  end

  def codec_builder(hash, k)
    codec = @base_codec.clone
    codec.use_mapper_auto_flush if using_mapped_auto_flush?
    compo = CodecValue.new(codec).tap do |o|
      now = Time.now
      o.eviction_timeout = eviction_timestamp(now)
      o.auto_flush_timeout = auto_flush_timestamp(now)
    end
    hash.store(k, compo)
  end

  def no_streams?
    identity_map.empty?
  end

  def using_mapped_auto_flush?
    !@auto_flush_interval.nil?
  end
end end end
