# This file is distributed under New Relic's license terms.
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details.
# frozen_string_literal: true

module Performance
  class TestCase
    DEFAULT_WARMUP_DURATION = 2
    DEFAULT_DURATION = 5
    BATCH_TIME = 0.1

    @subclasses = []

    def self.inherited(cls)
      @subclasses << cls
    end

    def self.subclasses
      @subclasses
    end

    attr_accessor :target_iterations, :target_duration

    def initialize
      @callbacks = {}
      @target_iterations = nil
      @target_duration = DEFAULT_DURATION
    end

    def setup; end

    def teardown; end

    def self.skip_test(test_method_name, options = {})
      skip_specifiers << [test_method_name, options]
    end

    def self.skip_specifiers
      @skip_specifiers ||= []
    end

    def on(event, &action)
      @callbacks[event] ||= []
      @callbacks[event] << action
    end

    def fire(event, *args)
      @callbacks[event]&.each { |cb| cb.arity > 0 ? cb.call(*args) : cb.call }
    end

    def runnable_test_methods
      results = self.methods.map(&:to_s).select { |m| m =~ /^test_/ }
      self.class.skip_specifiers.each do |specifier|
        method_name, options = *specifier
        skipped_platforms = Array(options[:platforms])
        skipped = Platform.current.match_any?(skipped_platforms)
        results.delete(method_name.to_s) if skipped
      end
      results
    end

    def with_callbacks(name, warmup = false)
      fire(:before_each, self, name) unless warmup
      yield
      fire(:after_each, self, name, @result) unless warmup
    end

    # This is used to get a rough approximation of the amount of time required
    # to call action once. We do this so that we will be able to run the actual
    # timing loop in chunks of iterations, rather than calling Process.clock_gettime
    # in between every iteration.
    def estimate_time_per_iteration(action, duration)
      start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
      deadline = start_time + duration

      iterations = 0
      while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
        action.call
        iterations += 1
      end

      (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) / iterations
    end

    def estimate_iterations_per_unit_time(time_per_iteration, desired_duration)
      (desired_duration / time_per_iteration).ceil
    end

    def batch_size_in_iterations(blk)
      approx_time_per_iteration = nil
      approx_time_per_iteration = estimate_time_per_iteration(blk, DEFAULT_WARMUP_DURATION)
      estimate_iterations_per_unit_time(approx_time_per_iteration, BATCH_TIME)
    end

    def run_block_in_batches(blk, duration, batch_size)
      deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + duration
      total_iterations = 0
      while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
        batch_iterations = 0
        while batch_iterations < batch_size
          blk.call
          batch_iterations += 1
        end
        total_iterations += batch_iterations
      end
      total_iterations
    end

    def run_block_n_times(blk, n)
      iterations = 0
      while iterations < n
        blk.call
        iterations += 1
      end
      iterations
    end

    def measure(desired_iterations = nil, &blk)
      target_iterations ||= desired_iterations
      total_iterations = 0
      start_time = nil
      elapsed = nil

      batch_size = batch_size_in_iterations(blk) unless target_iterations

      with_callbacks(@result.test_name, false) do
        start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
        if target_iterations
          total_iterations = run_block_n_times(blk, target_iterations)
        else
          total_iterations = run_block_in_batches(blk, target_duration, batch_size)
        end
        elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
      end

      @result.iterations = total_iterations
      @result.timer.start_timestamp = start_time
      @result.timer.elapsed = elapsed
    end

    def run(name)
      @result = Result.new(self.class, name)
      begin
        setup
        self.send(name)
        teardown
      rescue StandardError, LoadError => e
        @result.exception = e
      end
      @result
    end
  end
end
