# typed: false

require 'json'
#
# Compare across Rubies:
#   ASDF_RUBY_VERSION=3.4.10 asdf exec ruby bugreports/repro_gc.rb
#   ASDF_RUBY_VERSION=4.0.6  asdf exec ruby bugreports/repro_gc.rb
#
# What to watch: heap_live_slots should stay roughly flat across the run (no
# object leak -- everything is reclaimable garbage). On 3.4.x, RSS and
# heap_empty_pages should track that flat live-slot count closely, because
# empty pages get unmapped soon after a major GC. On 4.0.x, heap_empty_pages
# and RSS should climb and plateau *above* where 3.4.x settles and never come
# back down, because the release gate (max_free = total_slots * 0.65) shrinks
# its own denominator every time pages are unlinked, making the threshold
# unreachable after the first release round.
#
# Tunable via env: ITERATIONS, PRINT_EVERY, FULL_GC_EVERY, IDLE_MS, RETAIN_EVERY.

ITERATIONS     = (ENV['ITERATIONS']     || 200_000).to_i
PRINT_EVERY    = (ENV['PRINT_EVERY']    || 200).to_i
FULL_GC_EVERY  = (ENV['FULL_GC_EVERY']  || 25).to_i
IDLE_MS        = (ENV['IDLE_MS']        || 2).to_i
RETAIN_EVERY   = (ENV['RETAIN_EVERY']   || 0).to_i # 0 = no simulated cache growth
FREE_RATIO_GATE = 0.65 # Ruby 4.0's page-release gate: freeable when heap_free_slots / heap_available_slots exceeds this
MEGA_STRING_BYTES = (ENV['MEGA_STRING_BYTES'] || 200_000).to_i

RETAINED = []

FIELD_NAMES =('a'..'i').to_a.freeze

def random_field_ref
  "#{FIELD_NAMES.sample}.#{rand(1_000)}"
end

# Mirrors the shape of objects being created
def simulate_workload_1
  run = { id: "p_#{rand(1_000_000_000)}", a: 'b', c: {}, d: [] }

  (rand(5..19)).times do |i|
    step = {
      id: "s_#{rand(1_000_000_000)}",
      i: i,
      a: ('a'..'f').to_a.sample,
      c: {},
      cr: {},
      l: [],
    }

    12.times { |j| step[:c]["f_#{j}"] = random_field_ref }
    8.times { |j| step[:cr]["c_#{j}"] = rand < 0.5 ? rand(10_000) : "_#{rand(10_000)}" }
    20.times { |j| step[:l] << "w=#{step[:id]} l=#{j} c=#{random_field_ref}" }

    run[:d] << step
    run[:c][step[:id]] = { r_at: Time.now.to_f, a: step[:a] }
  end

  run[:blob] = simulate_blob(run[:d])

  run
end

# Mirrors large file downloaded from remote storage which is forwarded to JSON.parse
def simulate_blob(steps)
  mega_string = ('x' * MEGA_STRING_BYTES) + JSON.generate(steps)
  JSON.parse(mega_string[MEGA_STRING_BYTES..])
  mega_string
end

# Mirrors the shape of objects being created
def simulate_workload_2
  Array.new(rand(3..12)) do |i|
    {
      l: i,
      mc: Array.new(rand(2..5)) { random_field_ref },
      rv: rand < 0.3 ? nil : "r_#{rand(100_000)}",
    }
  end
end

def rss_mb
  `ps -o rss= -p #{Process.pid}`.to_i / 1024
end

def print_stats(iteration, start_time)
  stat = GC.stat
  elapsed = Time.now - start_time
  free_ratio = stat[:heap_free_slots].to_f / stat[:heap_available_slots]

  # GC.stat only exposes malloc_increase_bytes, which resets on every GC --
  # accumulate it ourselves for a running total across the whole run.
  @last_malloc_increase ||= 0
  @total_malloc_bytes ||= 0
  @total_malloc_bytes += stat[:malloc_increase_bytes] >= @last_malloc_increase ? stat[:malloc_increase_bytes] - @last_malloc_increase : stat[:malloc_increase_bytes]
  @last_malloc_increase = stat[:malloc_increase_bytes]

  printf(
    "iter=%-7d elapsed=%6.1fs rss=%6dMB live_slots=%9d available_slots=%9d empty_pages=%6d allocated_pages=%6d free_ratio=%.3f total_malloc_bytes=%12dB major_gc=%5d minor_gc=%6d malloc_increase=%9dB\n",
    iteration,
    elapsed,
    rss_mb,
    stat[:heap_live_slots],
    stat[:heap_available_slots],
    stat[:heap_empty_pages],
    stat[:heap_allocated_pages],
    free_ratio,
    @total_malloc_bytes,
    stat[:major_gc_count],
    stat[:minor_gc_count],
    stat[:malloc_increase_bytes],
  )
end

puts "ruby=#{RUBY_VERSION} yjit=#{defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled?} " \
     "iterations=#{ITERATIONS} full_gc_every=#{FULL_GC_EVERY} idle_ms=#{IDLE_MS} retain_every=#{RETAIN_EVERY} " \
     "boot_rss=#{rss_mb}MB"

# Warm the heap to a steady baseline before measuring, the same call
# production makes before fork -- and the call that flips on the GC's
# post-warmup page-release path.
500.times do
  simulate_workload_1
  simulate_workload_2
end
Process.warmup

start_time = Time.now
print_stats(0, start_time)

ITERATIONS.times do |i|
  simulate_workload_1
  simulate_workload_2

  RETAINED << simulate_workload_1 if RETAIN_EVERY > 0 && (i % RETAIN_EVERY).zero?

  GC.start if FULL_GC_EVERY > 0 && (i % FULL_GC_EVERY).zero?

  sleep(IDLE_MS / 1000.0) if IDLE_MS > 0

  print_stats(i + 1, start_time) if ((i + 1) % PRINT_EVERY).zero?
end

print_stats(ITERATIONS, start_time)
