# frozen_string_literal: true
require 'bundler/setup'
require 'tempfile'
require 'tmpdir'
require 'timeout'

require 'parallel_tests'
require 'parallel_tests/test/runtime_logger'
require 'parallel_tests/rspec/runtime_logger'
require 'parallel_tests/rspec/summary_logger'

String.class_eval do
  def strip_heredoc
    gsub(/^#{self[/^\s*/]}/, '')
  end
end

OutputLogger = Struct.new(:output) do
  attr_reader :flock, :flush

  def puts(s = nil)
    output << "#{s}\n"
  end

  def print(s = nil)
    output << s.to_s
  end
end

module SpecHelper
  def mocked_process
    StringIO.new
  end

  def size_of(group)
    group.map { |test| File.stat(test).size }.inject(:+)
  end

  def use_temporary_directory(&block)
    Dir.mktmpdir { |dir| Dir.chdir(dir, &block) }
  end

  def with_files(files)
    Dir.mktmpdir do |root|
      files.each do |file|
        parent = "#{root}/#{File.dirname(file)}"
        FileUtils.mkpath(parent) unless File.exist?(parent)
        FileUtils.touch(File.join(root, file))
      end
      yield root
    end
  end

  def should_run_with(regex)
    expect(ParallelTests::Test::Runner).to receive(:execute_command) do |a, _b, _c, _d|
      expect(a).to match(regex)
    end
  end

  def should_not_run_with(regex)
    expect(ParallelTests::Test::Runner).to receive(:execute_command) do |a, _b, _c, _d|
      expect(a).to_not match(regex)
    end
  end
end

module SharedExamples
  def test_tests_in_groups(klass, suffix)
    describe ".tests_in_groups" do
      let(:log) { klass.runtime_log }
      let(:test_root) { "temp" }

      around { |test| use_temporary_directory(&test) }

      before do
        FileUtils.mkdir test_root
        @files = [0, 1, 2, 3, 4, 5, 6, 7].map { |i| "#{test_root}/x#{i}#{suffix}" }
        @files.each { |file| File.write(file, 'x' * 100) }
        FileUtils.mkdir_p File.dirname(log)
      end

      def setup_runtime_log # rubocop:disable Lint/NestedMethodDefinition
        File.open(log, 'w') do |f|
          @files[1..-1].each { |file| f.puts "#{file}:#{@files.index(file)}" }
          f.puts "#{@files[0]}:10"
        end
      end

      it "groups when given an array of files" do
        list_of_files = Dir["#{test_root}/**/*#{suffix}"]
        result = list_of_files.dup
        klass.send(:sort_by_filesize, result)
        expect(result).to match_array(list_of_files.map { |file| [file, File.stat(file).size] })
      end

      it "finds all tests" do
        found = klass.tests_in_groups([test_root], 1)
        all = [Dir["#{test_root}/**/*#{suffix}"]]
        expect(found.flatten - all.flatten).to eq([])
      end

      it "partitions them into groups by equal size" do
        groups = klass.tests_in_groups([test_root], 2)
        expect(groups.map { |g| size_of(g) }).to eq([400, 400])
      end

      it 'should partition correctly with a group size of 4' do
        groups = klass.tests_in_groups([test_root], 4)
        expect(groups.map { |g| size_of(g) }).to eq([200, 200, 200, 200])
      end

      it 'should partition correctly with an uneven group size' do
        groups = klass.tests_in_groups([test_root], 3)
        expect(groups.map { |g| size_of(g) }).to match_array([300, 300, 200])
      end

      it "partitions by runtime when runtime-data is available" do
        allow(klass).to receive(:puts)
        setup_runtime_log

        groups = klass.tests_in_groups([test_root], 2)
        expect(groups.size).to eq(2)
        # 10 + 1 + 3 + 5 = 19
        expect(groups[0]).to eq([@files[0], @files[1], @files[3], @files[5]])
        # 2 + 4 + 6 + 7 = 19
        expect(groups[1]).to eq([@files[2], @files[4], @files[6], @files[7]])
      end

      it 'partitions from custom runtime-data location' do
        allow(klass).to receive(:puts)
        allow_any_instance_of(klass).to receive(:runtime_log).and_return('tmp/custom_runtime.log')
        setup_runtime_log

        groups = klass.tests_in_groups([test_root], 2, runtime_log: log)
        expect(groups.size).to eq(2)
        # 10 + 1 + 3 + 5 = 19
        expect(groups[0]).to eq([@files[0], @files[1], @files[3], @files[5]])
        # 2 + 4 + 6 + 7 = 19
        expect(groups[1]).to eq([@files[2], @files[4], @files[6], @files[7]])
      end

      it "alpha-sorts partitions when runtime-data is available" do
        allow(klass).to receive(:puts)
        setup_runtime_log

        groups = klass.tests_in_groups([test_root], 2)
        expect(groups.size).to eq(2)

        expect(groups[0]).to eq(groups[0].sort)
        expect(groups[1]).to eq(groups[1].sort)
      end

      it "partitions by round-robin when not sorting" do
        files = ["file1.rb", "file2.rb", "file3.rb", "file4.rb"]
        expect(klass).to receive(:find_tests).and_return(files)
        groups = klass.tests_in_groups(files, 2, group_by: :found).sort
        expect(groups[0]).to eq(["file1.rb", "file3.rb"])
        expect(groups[1]).to eq(["file2.rb", "file4.rb"])
      end

      it "alpha-sorts partitions when not sorting by runtime" do
        files = ['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'z', 'x', 'c', 'v', 'b', 'n', 'm']
        expect(klass).to receive(:find_tests).and_return(files)
        groups = klass.tests_in_groups(files, 2, group_by: :found).sort
        expect(groups[0]).to eq(groups[0].sort)
        expect(groups[1]).to eq(groups[1].sort)
      end
    end
  end
end

RSpec::Matchers.define :include_exactly_times do |expected, times|
  match do |actual|
    actual.scan(expected).size == times
  end

  failure_message do |actual|
    action = (expected.is_a?(String) ? "to contain '#{expected}'" : "to match /#{expected}/")
    outcome = (expected.is_a?(String) ? "appears" : "matches")
    <<~FAILURE
      expected the following string:
      """"
      #{actual}
      """"
      #{action} #{times} time(s), but it #{outcome} #{actual.scan(expected).size} time(s)
    FAILURE
  end
end

TestTookTooLong = Class.new(Timeout::Error)

RSpec.configure do |config|
  config.filter_run focus: true
  config.run_all_when_everything_filtered = true
  config.include SpecHelper
  config.extend SharedExamples

  config.raise_errors_for_deprecations!

  # sometimes stuff hangs -> do not hang everything
  # NOTE: the timeout error can sometimes swallow errors, comment it out if you run into trouble
  config.include(
    Module.new do
      def test_timeout
        30
      end
    end
  )
  config.around do |example|
    Timeout.timeout(test_timeout, TestTookTooLong, &example)
  end

  config.after do
    ENV.delete "PARALLEL_TEST_GROUPS"
    ENV.delete "PARALLEL_TEST_PROCESSORS"
    ENV.delete "PARALLEL_TESTS_EXECUTABLE"
    ENV.delete "TEST_ENV_NUMBER"
    ENV.delete "RAILS_ENV"
  end
end
