# frozen_string_literal: true

module CachedCounting
  extend ActiveSupport::Concern

  LUA_HGET_DEL = DiscourseRedis::EvalHelper.new <<~LUA
    local result = redis.call("HGET", KEYS[1], KEYS[2])
    redis.call("HDEL", KEYS[1], KEYS[2])

    return result
  LUA

  QUEUE = Queue.new
  SLEEP_SECONDS = 1
  FLUSH_DB_ITERATIONS = 60
  MUTEX = Mutex.new

  def self.disable
    @enabled = false
    if @thread && @thread.alive?
      @thread.wakeup
      @thread.join
    end
  end

  def self.enabled?
    @enabled != false
  end

  def self.enable
    @enabled = true
  end

  def self.reset
    @last_ensure_thread = nil
    clear_queue!
    clear_flush_to_db_lock!
  end

  ENSURE_THREAD_COOLDOWN_SECONDS = 5

  def self.ensure_thread!
    return if !enabled?

    MUTEX.synchronize do
      now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
      delta = @last_ensure_thread && (now - @last_ensure_thread)

      if delta && delta < ENSURE_THREAD_COOLDOWN_SECONDS
        # creating threads can be very expensive and bog down a process
        return
      end

      @last_ensure_thread = now

      @thread = nil if !@thread&.alive?
      @thread ||= Thread.new { thread_loop }
    end
  end

  def self.thread_loop
    iterations = 0
    while true
      break if !enabled?

      sleep SLEEP_SECONDS
      flush_in_memory
      if (iterations >= FLUSH_DB_ITERATIONS) || @flush
        iterations = 0
        flush_to_db
        @flush = false
      end
      iterations += 1
    end
  rescue => ex
    if Redis::CommandError === ex && ex.message =~ /READONLY/
      # do not warn for Redis readonly mode
    elsif PG::ReadOnlySqlTransaction === ex
      # do not warn for PG readonly mode
    else
      Discourse.warn_exception(ex, message: "Unexpected error while processing cached counts")
    end
  end

  def self.flush
    @flush = true
    @thread.wakeup
    sleep 0.001 while @flush
  end

  COUNTER_REDIS_HASH = "CounterCacheHash"

  def self.flush_in_memory
    counts = nil
    while QUEUE.length > 0
      # only 1 consumer, no need to avoid blocking
      key, klass, db, time = QUEUE.deq
      _redis_key = "#{klass},#{db},#{time.strftime("%Y%m%d")},#{key}"
      counts ||= Hash.new(0)
      counts[_redis_key] += 1
    end

    if counts
      counts.each do |redis_key, count|
        Discourse.redis.without_namespace.hincrby(COUNTER_REDIS_HASH, redis_key, count)
      end
    end
  end

  DB_FLUSH_COOLDOWN_SECONDS = 60
  DB_COOLDOWN_KEY = "cached_counting_cooldown"

  def self.flush_to_db
    redis = Discourse.redis.without_namespace
    DistributedMutex.synchronize("flush_counters_to_db", redis: redis, validity: 5.minutes) do
      if allowed_to_flush_to_db?
        redis
          .hkeys(COUNTER_REDIS_HASH)
          .each do |key|
            val = LUA_HGET_DEL.eval(redis, [COUNTER_REDIS_HASH, key]).to_i

            # unlikely (protected by mutex), but protect just in case
            # could be a race condition in test
            if val > 0
              klass_name, db, date, local_key = key.split(",", 4)
              date = Date.strptime(date, "%Y%m%d")
              klass = Module.const_get(klass_name)

              RailsMultisite::ConnectionManagement.with_connection(db) do
                klass.write_cache!(local_key, val, date)
              end
            end
          end
      end
    end
  end

  def self.clear_flush_to_db_lock!
    Discourse.redis.without_namespace.del(DB_COOLDOWN_KEY)
  end

  def self.flush_to_db_lock_ttl
    Discourse.redis.without_namespace.ttl(DB_COOLDOWN_KEY)
  end

  def self.allowed_to_flush_to_db?
    Discourse.redis.without_namespace.set(
      DB_COOLDOWN_KEY,
      "1",
      ex: DB_FLUSH_COOLDOWN_SECONDS,
      nx: true,
    )
  end

  def self.queue(key, klass)
    QUEUE.push([key, klass, RailsMultisite::ConnectionManagement.current_db, Time.now.utc])
  end

  def self.clear_queue!
    QUEUE.clear
    redis = Discourse.redis.without_namespace
    redis.del(COUNTER_REDIS_HASH)
  end

  class_methods do
    def perform_increment!(key)
      CachedCounting.ensure_thread!
      CachedCounting.queue(key, self)
    end

    def write_cache!(key, count, date)
      raise NotImplementedError
    end
  end
end
