# frozen_string_literal: true

require 'date'
require 'fileutils'
require 'minitar'
require 'test/unit'
require 'mocha/test_unit'
require 'tmpdir'

require "git"

$stdout.sync = true
$stderr.sync = true

class Test::Unit::TestCase

  TEST_ROOT = File.expand_path(__dir__)
  TEST_FIXTURES = File.join(TEST_ROOT, 'files')

  BARE_REPO_PATH = File.join(TEST_FIXTURES, 'working.git')

  def clone_working_repo
    @wdir = create_temp_repo('working')
  end

  teardown
  def git_teardown
    FileUtils.rm_r(@tmp_path) if instance_variable_defined?(:@tmp_path)
  end

  def in_bare_repo_clone
    in_temp_dir do |path|
      git = Git.clone(BARE_REPO_PATH, 'bare')
      Dir.chdir('bare') do
        yield git
      end
    end
  end

  def in_temp_repo(clone_name)
    clone_path = create_temp_repo(clone_name)
    Dir.chdir(clone_path) do
      yield
    end
  end

  def create_temp_repo(clone_name)
    clone_path = File.join(TEST_FIXTURES, clone_name)
    filename = 'git_test' + Time.now.to_i.to_s + rand(300).to_s.rjust(3, '0')
    path = File.expand_path(File.join(Dir.tmpdir, filename))
    FileUtils.mkdir_p(path)
    @tmp_path = File.realpath(path)
    FileUtils.cp_r(clone_path, @tmp_path)
    tmp_path = File.join(@tmp_path, File.basename(clone_path))
    FileUtils.cd tmp_path do
      FileUtils.mv('dot_git', '.git')
    end
    tmp_path
  end

  # Creates a temp directory and yields that path to the passed block
  #
  # On Windows, using Dir.mktmpdir with a block sometimes raises an error:
  # `Errno::ENOTEMPTY: Directory not empty @ dir_s_rmdir`. I think this might
  # be a configuration issue with the Windows CI environment.
  #
  # This was worked around by using the non-block form of Dir.mktmpdir and
  # then removing the directory manually in an ensure block.
  #
  def in_temp_dir
    tmpdir = Dir.mktmpdir
    tmpdir_realpath = File.realpath(tmpdir)
    Dir.chdir(tmpdir_realpath) do
      yield tmpdir_realpath
    end
  ensure
    FileUtils.rm_rf(tmpdir_realpath) if tmpdir_realpath
    # raise "Temp dir #{tmpdir} not removed. Remaining files : #{Dir["#{tmpdir}/**/*"]}" if File.exist?(tmpdir)
  end

  def create_file(path, content)
    File.open(path,'w') do |file|
      file.puts(content)
    end
  end

  def update_file(path, content)
    create_file(path,content)
  end

  def delete_file(path)
    File.delete(path)
  end

  def move_file(source_path, target_path)
    File.rename source_path, target_path
  end

  def new_file(name, contents)
    create_file(name,contents)
  end

  def append_file(name, contents)
    File.open(name, 'a') do |f|
      f.puts contents
    end
  end

  # Assert that the expected command line is generated by a given Git::Base method
  #
  # This assertion generates an empty git repository and then yields to the
  # given block passing the Git::Base instance for the empty repository. The
  # current directory is set to the root of the repository's working tree.
  #
  #
  # @example Test that calling `git.fetch` generates the command line `git fetch`
  #   # Only need to specify the arguments to the git command
  #   expected_command_line = ['fetch']
  #   assert_command_line_eq(expected_command_line) { |git| git.fetch }
  #
  # @example Test that calling `git.fetch('origin', { ref: 'master', depth: '2' })` generates the command line `git fetch --depth 2 -- origin master`
  #   expected_command_line = ['fetch', '--depth', '2', '--', 'origin', 'master']
  #   assert_command_line_eq(expected_command_line) { |git| git.fetch('origin', { ref: 'master', depth: '2' }) }
  #
  # @param expected_command_line [Array<String>] The expected arguments to be sent to Git::Lib#command
  # @param git_output [String] The mocked output to be returned by the Git::Lib#command method
  #
  # @yield [git] a block to call the method to be tested
  # @yieldparam git [Git::Base] The Git::Base object resulting from initializing the test project
  # @yieldreturn [void] the return value of the block is ignored
  #
  # @return [void]
  #
  def assert_command_line_eq(expected_command_line, method: :command, mocked_output: nil)
    actual_command_line = nil

    command_output = ''

    in_temp_dir do |path|
      git = Git.init('test_project')

      git.lib.define_singleton_method(method) do |*cmd, **opts, &block|
        actual_command_line = [*cmd, opts]
        mocked_output
      end

      Dir.chdir 'test_project' do
        yield(git) if block_given?
      end
    end

    assert_equal(expected_command_line, actual_command_line)

    command_output
  end

  def assert_child_process_success(&block)
    yield
    assert_equal 0, $CHILD_STATUS.exitstatus, "Child process failed with exitstatus #{$CHILD_STATUS.exitstatus}"
  end

  def windows_platform?
    # Check if on Windows via RUBY_PLATFORM (CRuby) and RUBY_DESCRIPTION (JRuby)
    win_platform_regex = /mingw|mswin/
    RUBY_PLATFORM =~ win_platform_regex || RUBY_DESCRIPTION =~ win_platform_regex
  end

  require 'delegate'

  # A wrapper around a ProcessExecuter::Status that also includes command output
  # @api public
  class CommandResult < SimpleDelegator
    # Create a new CommandResult
    # @example
    #   status = ProcessExecuter.spawn(*command, timeout:, out:, err:)
    #   CommandResult.new(status, out_buffer.string, err_buffer.string)
    # @param status [ProcessExecuter::Status] The status of the process
    # @param out [String] The standard output of the process
    # @param err [String] The standard error of the process
    def initialize(status, out, err)
      super(status)
      @out = out
      @err = err
    end

    # @return [String] The stdout output of the process
    attr_reader :out

    # @return [String] The stderr output of the process
    attr_reader :err
  end

  # Run a command and return the status including stdout and stderr output
  #
  # @example
  #   command = %w[git status]
  #   status = run(command)
  #   status.success? # => true
  #   status.exitstatus # => 0
  #   status.out # => "On branch master\nnothing to commit, working tree clean\n"
  #   status.err # => ""
  #
  # @param command [Array<String>] The command to run
  # @param timeout [Numeric, nil] Seconds to allow command to run before killing it or nil for no timeout
  # @param raise_errors [Boolean] Raise an exception if the command fails
  # @param error_message [String] The message to use when raising an exception
  #
  # @return [CommandResult] The result of running
  #
  def run_command(*command, timeout: nil, raise_errors: true, error_message: "#{command[0]} failed")
    out_buffer = StringIO.new
    out = ProcessExecuter::MonitoredPipe.new(out_buffer)
    err_buffer = StringIO.new
    err = ProcessExecuter::MonitoredPipe.new(err_buffer)

    status = ProcessExecuter.spawn(*command, timeout: timeout, out: out, err: err)

    raise "#{error_message}: #{err_buffer.string}" if raise_errors && !status.success?

    CommandResult.new(status, out_buffer.string, err_buffer.string)
  end
end

# Replace the default git binary with the given script
#
# This method creates a temporary directory and writes the given script to a file
# named `git` in a subdirectory named `bin`. This subdirectory name can be changed by
# passing a different value for the `subdir` parameter.
#
# On non-windows platforms, make sure the script starts with a hash bang. On windows,
# make sure the script has a `.bat` extension.
#
# On non-windows platforms, the script is made executable.
#
# `Git::Base.config.binary_path` set to the path to the script.
#
# The block is called passing the path to the mocked git binary.
#
# `Git::Base.config.binary_path` is reset to its original value after the block
# returns.
#
# @example mocked_git_script = <<~GIT_SCRIPT #!/bin/sh puts 'git version 1.2.3'
#   GIT_SCRIPT
#
#   mock_git_binary(mocked_git_script) do
#     # Run Git commands here -- they will call the mocked git script
#   end
#
# @param script [String] The bash script to run instead of the real git binary
#
# @param subdir [String] The subdirectory to place the mocked git binary in
#
# @yield Call the block while the git binary is mocked
#
# @yieldparam git_binary_path [String] The path to the mocked git binary
#
# @yieldreturn [void] the return value of the block is ignored
#
# @return [void]
#
def mock_git_binary(script, subdir: 'bin')
  Dir.mktmpdir do |binary_dir|
    binary_name = windows_platform? ? 'git.bat' : 'git'
    git_binary_path = File.join(binary_dir, subdir, binary_name)
    FileUtils.mkdir_p(File.dirname(git_binary_path))
    File.write(git_binary_path, script)
    File.chmod(0755, git_binary_path) unless windows_platform?
    saved_binary_path = Git::Base.config.binary_path
    Git::Base.config.binary_path = git_binary_path

    yield git_binary_path

    Git::Base.config.binary_path = saved_binary_path
  end
end
