$:.unshift File.expand_path('../../lib', __FILE__)
require 'stackprof'
require 'minitest/autorun'
require 'tempfile'
require 'pathname'

class StackProfTest < Minitest::Test
  def setup
    Object.new # warm some caches to avoid flakiness
  end

  def test_info
    profile = StackProf.run{}
    assert_equal 1.2, profile[:version]
    assert_equal :wall, profile[:mode]
    assert_equal 1000, profile[:interval]
    assert_equal 0, profile[:samples]
  end

  def test_running
    assert_equal false, StackProf.running?
    StackProf.run{ assert_equal true, StackProf.running? }
  end

  def test_start_stop_results
    assert_nil StackProf.results
    assert_equal true, StackProf.start
    assert_equal false, StackProf.start
    assert_equal true, StackProf.running?
    assert_nil StackProf.results
    assert_equal true, StackProf.stop
    assert_equal false, StackProf.stop
    assert_equal false, StackProf.running?
    assert_kind_of Hash, StackProf.results
    assert_nil StackProf.results
  end

  def test_object_allocation
    profile_base_line = __LINE__+1
    profile = StackProf.run(mode: :object) do
      Object.new
      Object.new
    end
    assert_equal :object, profile[:mode]
    assert_equal 1, profile[:interval]
    if RUBY_VERSION >= '3'
      assert_equal 4, profile[:samples]
    else
      assert_equal 2, profile[:samples]
    end

    frame = profile[:frames].values.first
    assert_includes frame[:name], "StackProfTest#test_object_allocation"
    assert_equal 2, frame[:samples]
    assert_includes [profile_base_line - 2, profile_base_line], frame[:line]
    if RUBY_VERSION >= '3'
      assert_equal [2, 1], frame[:lines][profile_base_line+1]
      assert_equal [2, 1], frame[:lines][profile_base_line+2]
    else
      assert_equal [1, 1], frame[:lines][profile_base_line+1]
      assert_equal [1, 1], frame[:lines][profile_base_line+2]
    end
    frame = profile[:frames].values[1] if RUBY_VERSION < '2.3'

    if RUBY_VERSION >= '3'
      assert_equal [4, 0], frame[:lines][profile_base_line]
    else
      assert_equal [2, 0], frame[:lines][profile_base_line]
    end
  end

  def test_object_allocation_interval
    profile = StackProf.run(mode: :object, interval: 10) do
      100.times { Object.new }
    end
    assert_equal 10, profile[:samples]
  end

  def test_cputime
    profile = StackProf.run(mode: :cpu, interval: 500) do
      math
    end

    assert_operator profile[:samples], :>=, 1
    if RUBY_VERSION >= '3'
      assert profile[:frames].values.take(2).map { |f|
        f[:name].include? "StackProfTest#math"
      }.any?
    else
      frame = profile[:frames].values.first
      assert_includes frame[:name], "StackProfTest#math"
    end
  end

  def test_walltime
    GC.disable
    profile = StackProf.run(mode: :wall) do
      idle
    end

    frame = profile[:frames].values.first
    if RUBY_VERSION >= '3'
      assert_equal "IO.select", frame[:name]
    else
      assert_equal "StackProfTest#idle", frame[:name]
    end
    assert_in_delta 200, frame[:samples], 25
  ensure
    GC.enable
  end

  def test_custom
    profile_base_line = __LINE__+1
    profile = StackProf.run(mode: :custom) do
      10.times do
        StackProf.sample
      end
    end

    assert_equal :custom, profile[:mode]
    assert_equal 10, profile[:samples]

    offset = RUBY_VERSION >= '3' ? 1 : 0
    frame = profile[:frames].values[offset]
    assert_includes frame[:name], "StackProfTest#test_custom"
    assert_includes [profile_base_line-2, profile_base_line+1], frame[:line]

    if RUBY_VERSION >= '3'
      assert_equal [10, 0], frame[:lines][profile_base_line+2]
    else
      assert_equal [10, 10], frame[:lines][profile_base_line+2]
    end
  end

  def test_raw
    before_monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)

    profile = StackProf.run(mode: :custom, raw: true) do
      10.times do
        StackProf.sample
        sleep 0.0001
      end
    end

    after_monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)

    raw = profile[:raw]
    raw_lines = profile[:raw_lines]
    assert_equal 10, raw[-1]
    assert_equal raw[0] + 2, raw.size
    assert_equal 10, raw_lines[-1] # seen 10 times

    offset = RUBY_VERSION >= '3' ? -3 : -2
    assert_equal 140, raw_lines[offset] # sample caller is on 140
    assert_includes profile[:frames][raw[offset]][:name], 'StackProfTest#test_raw'

    assert_equal 10, profile[:raw_sample_timestamps].size
    profile[:raw_sample_timestamps].each_cons(2) do |t1, t2|
      assert_operator t1, :>, before_monotonic
      assert_operator t2, :>=, t1
      assert_operator t2, :<, after_monotonic
    end

    assert_equal 10, profile[:raw_timestamp_deltas].size
    total_duration = after_monotonic - before_monotonic
    assert_operator profile[:raw_timestamp_deltas].inject(&:+), :<, total_duration

    profile[:raw_timestamp_deltas].each do |delta|
      assert_operator delta, :>, 0
    end
  end

  def test_metadata
    metadata = {
      path: '/foo/bar',
      revision: '5c0b01f1522ae8c194510977ae29377296dd236b',
    }
    profile = StackProf.run(mode: :cpu, metadata: metadata) do
      math
    end

    assert_equal metadata, profile[:metadata]
  end

  def test_empty_metadata
    profile = StackProf.run(mode: :cpu) do
      math
    end

    assert_equal({}, profile[:metadata])
  end

  def test_raises_if_metadata_is_not_a_hash
    exception = assert_raises ArgumentError do
      StackProf.run(mode: :cpu, metadata: 'foobar') do
        math
      end
    end

    assert_equal 'metadata should be a hash', exception.message
  end

  def test_fork
    StackProf.run do
      pid = fork do
        exit! StackProf.running?? 1 : 0
      end
      Process.wait(pid)
      assert_equal 0, $?.exitstatus
      assert_equal true, StackProf.running?
    end
  end

  def foo(n = 10)
    if n == 0
      StackProf.sample
      return
    end
    foo(n - 1)
  end

  def test_recursive_total_samples
    profile = StackProf.run(mode: :cpu, raw: true) do
      10.times do
        foo
      end
    end

    frame = profile[:frames].values.find do |frame|
      frame[:name] == "StackProfTest#foo"
    end
    assert_equal 10, frame[:total_samples]
  end

  def test_gc
    profile = StackProf.run(interval: 100, raw: true) do
      5.times do
        GC.start
      end
    end

    gc_frame = profile[:frames].values.find{ |f| f[:name] == "(garbage collection)" }
    marking_frame = profile[:frames].values.find{ |f| f[:name] == "(marking)" }
    sweeping_frame = profile[:frames].values.find{ |f| f[:name] == "(sweeping)" }

    assert gc_frame
    assert marking_frame
    assert sweeping_frame

    # We can't guarantee a certain number of GCs to run, so just assert
    # that it's within some kind of delta
    assert_in_delta gc_frame[:total_samples], profile[:gc_samples], 2

    # Lazy marking / sweeping can cause this math to not add up, so also use a delta
    assert_in_delta profile[:gc_samples], [gc_frame, marking_frame, sweeping_frame].map{|x| x[:samples] }.inject(:+), 2

    assert_operator profile[:gc_samples], :>, 0
    assert_operator profile[:missed_samples], :<=, 25
  end

  def test_out
    tmpfile = Tempfile.new('stackprof-out')
    ret = StackProf.run(mode: :custom, out: tmpfile) do
      StackProf.sample
    end

    assert_equal tmpfile, ret
    tmpfile.rewind
    profile = Marshal.load(tmpfile.read)
    refute_empty profile[:frames]
  end

  def test_out_to_path_string
    tmpfile = Tempfile.new('stackprof-out')
    ret = StackProf.run(mode: :custom, out: tmpfile.path) do
      StackProf.sample
    end

    refute_equal tmpfile, ret
    assert_equal tmpfile.path, ret.path
    tmpfile.rewind
    profile = Marshal.load(tmpfile.read)
    refute_empty profile[:frames]
  end

  def test_pathname_out
    tmpfile  = Tempfile.new('stackprof-out')
    pathname = Pathname.new(tmpfile.path)
    ret = StackProf.run(mode: :custom, out: pathname) do
      StackProf.sample
    end

    assert_equal tmpfile.path, ret.path
    tmpfile.rewind
    profile = Marshal.load(tmpfile.read)
    refute_empty profile[:frames]
  end

  def test_min_max_interval
    [-1, 0, 1_000_000, 1_000_001].each do |invalid_interval|
      err = assert_raises(ArgumentError, "invalid interval #{invalid_interval}") do
        StackProf.run(interval: invalid_interval, debug: true) {}
      end
      assert_match(/microseconds/, err.message)
    end
  end

  def math
    250_000.times do
      2 ** 10
    end
  end

  def idle
    r, w = IO.pipe
    IO.select([r], nil, nil, 0.2)
  ensure
    r.close
    w.close
  end
end unless RUBY_ENGINE == 'truffleruby'
