# frozen_string_literal: true
class LeakChecker
  @@try_lsof = nil # not-tried-yet

  def initialize
    @fd_info = find_fds
    @@skip = false
    @tempfile_info = find_tempfiles
    @thread_info = find_threads
    @env_info = find_env
    @encoding_info = find_encodings
    @old_verbose = $VERBOSE
    @old_warning_flags = find_warning_flags
  end

  def check(test_name)
    if /i386-solaris/ =~ RUBY_PLATFORM && /TestGem/ =~ test_name
      GC.verify_internal_consistency
    end

    leaks = [
      check_fd_leak(test_name),
      check_thread_leak(test_name),
      check_tempfile_leak(test_name),
      check_env(test_name),
      check_encodings(test_name),
      check_verbose(test_name),
      check_warning_flags(test_name),
    ]
    GC.start if leaks.any?
  end

  def check_verbose test_name
    puts "#{test_name}: $VERBOSE == #{$VERBOSE}" unless @old_verbose == $VERBOSE
  end

  def find_fds
    if IO.respond_to?(:console) and (m = IO.method(:console)).arity.nonzero?
      m[:close]
    end
    %w"/proc/self/fd /dev/fd".each do |fd_dir|
      if File.directory?(fd_dir)
        fds = Dir.open(fd_dir) {|d|
          a = d.grep(/\A\d+\z/, &:to_i)
          if d.respond_to? :fileno
            a -= [d.fileno]
          end
          a
        }
        return fds.sort
      end
    end
    []
  end

  def check_fd_leak(test_name)
    leaked = false
    live1 = @fd_info
    live2 = find_fds
    fd_closed = live1 - live2
    if !fd_closed.empty?
      fd_closed.each {|fd|
        puts "Closed file descriptor: #{test_name}: #{fd}"
      }
    end
    fd_leaked = live2 - live1
    if !@@skip && !fd_leaked.empty?
      leaked = true
      h = {}
      ObjectSpace.each_object(IO) {|io|
        inspect = io.inspect
        begin
          autoclose = io.autoclose?
          fd = io.fileno
        rescue IOError # closed IO object
          next
        end
        (h[fd] ||= []) << [io, autoclose, inspect]
      }
      fd_leaked.select! {|fd|
        str = ''.dup
        pos = nil
        if h[fd]
          str << ' :'
          h[fd].map {|io, autoclose, inspect|
            if ENV["LEAK_CHECKER_TRACE_OBJECT_ALLOCATION"]
              pos = "#{ObjectSpace.allocation_sourcefile(io)}:#{ObjectSpace.allocation_sourceline(io)}"
            end
            s = ' ' + inspect
            s << "(not-autoclose)" if !autoclose
            s
          }.sort.each {|s|
            str << s
          }
        else
          begin
            io = IO.for_fd(fd, autoclose: false)
            s = io.stat
          rescue Errno::EBADF
            # something un-stat-able
            next
          else
            next if /darwin/ =~ RUBY_PLATFORM and [0, -1].include?(s.dev)
            str << ' ' << s.inspect
          ensure
            io&.close
          end
        end
        puts "Leaked file descriptor: #{test_name}: #{fd}#{str}"
        puts "  The IO was created at #{pos}" if pos
        true
      }
      unless fd_leaked.empty?
        unless @@try_lsof == false
          @@try_lsof |= system(*%W[lsof -w -a -d #{fd_leaked.minmax.uniq.join("-")} -p #$$], out: Test::Unit::Runner.output)
        end
      end
      h.each {|fd, list|
        next if list.length <= 1
        if 1 < list.count {|io, autoclose, inspect| autoclose }
          str = list.map {|io, autoclose, inspect| " #{inspect}" + (autoclose ? "(autoclose)" : "") }.sort.join
          puts "Multiple autoclose IO objects for a file descriptor in: #{test_name}: #{str}"
        end
      }
    end
    @fd_info = live2
    @@skip = false
    return leaked
  end

  def extend_tempfile_counter
    return if defined? LeakChecker::TempfileCounter
    m = Module.new {
      @count = 0
      class << self
        attr_accessor :count
      end

      def new(...)
        LeakChecker::TempfileCounter.count += 1
        super
      end
    }
    LeakChecker.const_set(:TempfileCounter, m)

    class << Tempfile
      prepend LeakChecker::TempfileCounter
    end
  end

  def find_tempfiles(prev_count=-1)
    return [prev_count, []] unless defined? Tempfile
    extend_tempfile_counter
    count = TempfileCounter.count
    if prev_count == count
      [prev_count, []]
    else
      tempfiles = ObjectSpace.each_object(Tempfile).reject {|t|
        t.instance_variables.empty? || t.closed?
      }
      [count, tempfiles]
    end
  end

  def check_tempfile_leak(test_name)
    return false unless defined? Tempfile
    count1, initial_tempfiles = @tempfile_info
    count2, current_tempfiles = find_tempfiles(count1)
    leaked = false
    tempfiles_leaked = current_tempfiles - initial_tempfiles
    if !tempfiles_leaked.empty?
      leaked = true
      list = tempfiles_leaked.map {|t| t.inspect }.sort
      list.each {|str|
        puts "Leaked tempfile: #{test_name}: #{str}"
      }
      tempfiles_leaked.each {|t| t.close! }
    end
    @tempfile_info = [count2, initial_tempfiles]
    return leaked
  end

  def find_threads
    Thread.list.find_all {|t|
      t != Thread.current && t.alive? &&
        !(t.thread_variable?(:"\0__detached_thread__") && t.thread_variable_get(:"\0__detached_thread__"))
    }
  end

  def check_thread_leak(test_name)
    live1 = @thread_info
    live2 = find_threads
    thread_finished = live1 - live2
    leaked = false
    if !thread_finished.empty?
      list = thread_finished.map {|t| t.inspect }.sort
      list.each {|str|
        puts "Finished thread: #{test_name}: #{str}"
      }
    end
    thread_leaked = live2 - live1
    if !thread_leaked.empty?
      leaked = true
      list = thread_leaked.map {|t| t.inspect }.sort
      list.each {|str|
        puts "Leaked thread: #{test_name}: #{str}"
      }
    end
    @thread_info = live2
    return leaked
  end

  e = ENV["_Ruby_Env_Ignorecase_"], ENV["_RUBY_ENV_IGNORECASE_"]
  begin
    ENV["_Ruby_Env_Ignorecase_"] = ENV["_RUBY_ENV_IGNORECASE_"] = nil
    ENV["_RUBY_ENV_IGNORECASE_"] = "ENV_CASE_TEST"
    ENV_IGNORECASE = ENV["_Ruby_Env_Ignorecase_"] == "ENV_CASE_TEST"
  ensure
    ENV["_Ruby_Env_Ignorecase_"], ENV["_RUBY_ENV_IGNORECASE_"] = e
  end

  if ENV_IGNORECASE
    def find_env
      ENV.to_h {|k, v| [k.upcase, v]}
    end
  else
    def find_env
      ENV.to_h
    end
  end

  def check_env(test_name)
    old_env = @env_info
    new_env = find_env
    return false if old_env == new_env
    if defined?(Bundler::EnvironmentPreserver)
      bundler_prefix = Bundler::EnvironmentPreserver::BUNDLER_PREFIX
    end
    (old_env.keys | new_env.keys).sort.each {|k|
      # Don't report changed environment variables caused by Bundler's backups
      next if bundler_prefix and k.start_with?(bundler_prefix)

      if old_env.has_key?(k)
        if new_env.has_key?(k)
          if old_env[k] != new_env[k]
            puts "Environment variable changed: #{test_name} : #{k.inspect} changed : #{old_env[k].inspect} -> #{new_env[k].inspect}"
          end
        else
          puts "Environment variable changed: #{test_name} : #{k.inspect} deleted"
        end
      else
        if new_env.has_key?(k)
          puts "Environment variable changed: #{test_name} : #{k.inspect} added"
        else
          flunk "unreachable"
        end
      end
    }
    @env_info = new_env
    return true
  end

  def find_encodings
    {
      'Encoding.default_internal' => Encoding.default_internal,
      'Encoding.default_external' => Encoding.default_external,
      'STDIN.internal_encoding' => STDIN.internal_encoding,
      'STDIN.external_encoding' => STDIN.external_encoding,
      'STDOUT.internal_encoding' => STDOUT.internal_encoding,
      'STDOUT.external_encoding' => STDOUT.external_encoding,
      'STDERR.internal_encoding' => STDERR.internal_encoding,
      'STDERR.external_encoding' => STDERR.external_encoding,
    }
  end

  def check_encodings(test_name)
    old_encoding_info = @encoding_info
    @encoding_info = find_encodings
    leaked = false
    @encoding_info.each do |key, new_encoding|
      old_encoding = old_encoding_info[key]
      if new_encoding != old_encoding
        leaked = true
        puts "#{key} changed: #{test_name} : #{old_encoding.inspect} to #{new_encoding.inspect}"
      end
    end
    leaked
  end

  WARNING_CATEGORIES = (Warning.respond_to?(:[]) ? %i[deprecated experimental] : []).freeze

  def find_warning_flags
    WARNING_CATEGORIES.to_h do |category|
      [category, Warning[category]]
    end
  end

  def check_warning_flags(test_name)
    new_warning_flags = find_warning_flags
    leaked = false
    WARNING_CATEGORIES.each do |category|
      if new_warning_flags[category] != @old_warning_flags[category]
        leaked = true
        puts "Warning[#{category.inspect}] changed: #{test_name} : #{@old_warning_flags[category]} to #{new_warning_flags[category]}"
      end
    end
    return leaked
  end

  def puts(*a)
    output = Test::Unit::Runner.output
    if defined?(output.set_encoding)
      output.set_encoding(nil, nil)
    end
    output.puts(*a)
  end

  def self.skip
    @@skip = true
  end
end
