# frozen_string_literal: true

RSpec.describe GDK::Shellout do
  let(:command) { 'echo foo' }
  let(:opts) { {} }
  let(:tmp_directory) { File.realpath('/tmp') }

  subject { described_class.new(command, **opts) }

  describe '#args' do
    let(:command_as_array) { %w[echo foo] }

    context 'when command is a String' do
      it 'parses correctly' do
        expect(subject.args).to eq([command])
      end
    end

    context 'when command is an Array' do
      let(:command) { command_as_array }

      it 'parses correctly' do
        expect(subject.args).to eq(command)
      end
    end

    context 'when command is a series of arguments' do
      subject { described_class.new('echo', 'foo') }

      it 'parses correctly' do
        expect(subject.args).to eq(command_as_array)
      end
    end
  end

  describe '#command' do
    let(:command_as_array) { %w[echo foo] }

    it 'returns command as a string' do
      expect(subject.command).to eq('echo foo')
    end
  end

  describe '#exit_code' do
    describe '#run has not yet been executed' do
      it 'returns nil' do
        expect(subject.exit_code).to be_nil
      end
    end

    describe '#run has been executed' do
      before do
        subject.run
      end

      context 'when command is successful' do
        it 'returns 0' do
          expect(subject.exit_code).to be(0)
        end
      end

      context 'when command is not successful' do
        let(:command) { 'echo error 1>&2; exit 1' }

        it 'returns 1' do
          expect(subject.exit_code).to be(1)
        end
      end
    end
  end

  describe '#execute' do
    it 'returns self', :hide_stdout do
      expect(subject.execute).to eq(subject)
    end

    context 'by default' do
      it 'streams the output' do
        expect { subject.execute }.to output("foo\n").to_stdout
      end

      it 'logs the result, including success status, stdout, stderr, and duration', :hide_stdout do
        allow(GDK::Output).to receive(:debug)
        allow(Benchmark).to receive(:realtime).and_yield.and_return(2.0)

        expect(GDK::Output).to receive(:debug).with('result: success?=[true], stdout=[foo], stderr=[], duration=[2.0 seconds]')

        subject.execute
      end
    end

    context 'with display_output: false' do
      it 'does not stream the output' do
        expect { subject.execute(display_output: false) }.not_to output("foo\n").to_stdout
      end
    end

    context 'with display_error: false' do
      let(:command) { 'ls /doesntexist' }

      before do
        # Stubbing ENV crashes in Ruby 3.0: https://bugs.ruby-lang.org/issues/18164
        skip if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3.1.0')
      end

      it 'does not display the command failed message' do
        stub_no_color_env('true')

        expect { subject.execute(display_error: false) }.not_to output(%r{ERROR: 'ls /doesntexist' failed.}).to_stderr
        expect { subject.execute(display_error: true) }.to output(%r{ERROR: 'ls /doesntexist' failed.}).to_stderr
      end
    end

    context 'when the command fails completely' do
      shared_examples 'a command that fails' do
        it 'is unsuccessful', :hide_output do
          subject.execute

          expect(subject.success?).to be_falsey
        end

        it 'displays output and errors' do
          expect(GDK::Output).to receive(:print).with(expected_command_stderr_puts, stderr: true)
          expect(GDK::Output).to receive(:error).with(expected_command_error, GDK::Shellout::ShelloutBaseError)

          subject.execute
        end
      end

      shared_examples 'a command that does not retry' do
        it 'does not retry', :hide_output do
          expect(Kernel).not_to receive(:retry)

          subject.execute
        end
      end

      shared_examples 'a command that retries and fails' do
        it 'retries', :hide_output do
          expect(subject).to receive(:sleep).with(2).twice.and_return(true)
          expect(subject).to receive(expected_execute_method).exactly(3).times # 1 for the first run + 2 retries
          expect(subject).to receive(:success?).exactly(6).times.and_return(false)

          expect(GDK::Output).to receive(:error).with("'#{command}' failed. Retrying in 2 secs..", GDK::Shellout::ExecuteCommandFailedError).twice
          expect(GDK::Output).to receive(:error).with("'#{command}' failed.", GDK::Shellout::ExecuteCommandFailedError)

          subject.execute(display_output: display_output, retry_attempts: 2)
        end

        it 'is unsuccessful', :hide_output do
          allow(subject).to receive(:sleep).with(2).twice.and_return(true)
          subject.execute(display_output: display_output, retry_attempts: 2)

          expect(subject.success?).to be_falsey
        end
      end

      context 'when the command does not exist' do
        let(:command) { 'blah' }
        let(:expected_command_stderr_puts) { 'No such file or directory - blah' }
        let(:expected_command_error) { "'blah' failed." }

        it_behaves_like 'a command that fails'
        it_behaves_like 'a command that does not retry'
      end

      context 'when the command does exist, but fails' do
        let(:command) { 'ls /doesntexist' }
        let(:opts) { {} }
        let(:expected_command_stderr_puts) { "ls: cannot access '/doesntexist': No such file or directory\n" }
        let(:expected_command_error) { "'ls /doesntexist' failed." }

        before do
          stderr = StringIO.new(expected_command_stderr_puts)
          stdout = StringIO.new('')
          stdin = instance_double(StringIO, write: '', close: nil)

          allow(stdout).to receive(:wait_readable).and_return(true)
          allow(stderr).to receive(:wait_readable).and_return(true)

          allow(Open3).to receive(:popen3).with({}, command, opts).and_yield(stdin, stdout, stderr, Thread.new { nil })
        end

        it_behaves_like 'a command that fails'
        it_behaves_like 'a command that does not retry'

        context 'with display_output: true' do
          let(:display_output) { true }
          let(:expected_execute_method) { :stream }

          context 'with a retry specified' do
            it_behaves_like 'a command that fails'
            it_behaves_like 'a command that retries and fails'
          end
        end

        context 'with display_output: false' do
          let(:opts) { { err: '/dev/null' } }
          let(:display_output) { false }
          let(:expected_execute_method) { :try_run }

          context 'with a retry specified' do
            it_behaves_like 'a command that fails'
            it_behaves_like 'a command that retries and fails'
          end
        end
      end
    end

    context 'when the command fails once but ultimately succeeds' do
      let(:command) { 'ls /fakedir' }

      it 'fails once but then succeeds', :hide_output do
        allow(subject).to receive(:sleep).with(2).and_return(true)

        expect(subject).to receive(:success?).twice.and_return(false)
        expect(subject).to receive(:success?).twice.and_return(true)

        expect(GDK::Output).to receive(:success).with("'#{command}' succeeded after retry.")

        subject.execute(retry_attempts: 1)
      end
    end
  end

  describe '#stream' do
    let(:task_logger) { nil }

    it 'returns output of shell command', :hide_stdout do
      expect(subject.stream).to eq('foo')
    end

    it 'send output to stdout' do
      expect { subject.stream }.to output("foo\n").to_stdout
    end

    context 'with non UTF-8 output' do
      let(:mixed_encoding) { "🐤🐤🐤🐤\xF0\x9F\x90".dup.force_encoding('ASCII-8BIT') } # rubocop:disable Performance/UnfreezeString -- This doesn't work with frozen_string_literal set
      let(:clean_string) { GDK::Output.ensure_utf8(mixed_encoding) }
      let(:command) { "echo '#{mixed_encoding}'" }

      it 'returns cleaned UTF-8 output of shell command', :hide_stdout do
        expect(subject.stream).to eq(clean_string)
      end

      it 'sends cleaned UTF-8 output to stdout' do
        expect { subject.stream }.to output("#{clean_string}\n").to_stdout
      end
    end

    context 'when chdir: is specified' do
      let(:command) { 'pwd' }
      let(:opts) { { chdir: tmp_directory } }

      it 'changes into the specified directory before executing' do
        expect { expect(subject.stream).to eq(tmp_directory) }.to output("#{tmp_directory}\n").to_stdout
      end
    end

    context 'with a task logger' do
      let(:logger) { nil }

      before do
        allow(Support::Rake::TaskLogger).to receive(:current).and_return(task_logger)
      end

      it 'sets the task logger to nil by default' do
        expect(Support::Rake::TaskLogger).to receive(:set_current!).with(task_logger).twice

        expect { subject.stream }.to output("foo\n").to_stdout
      end

      context 'when the logger is set' do
        let(:task_logger) { instance_double(Support::Rake::TaskLogger) }

        it 'prints to the task logger instead of stdout' do
          buffer = StringIO.new

          expect(Support::Rake::TaskLogger).to receive(:set_current!).with(task_logger).twice
          expect(task_logger).to receive(:file).and_return(buffer)
          expect(task_logger).to receive(:record_input).with("foo\n")

          expect { subject.stream }.not_to output.to_stdout

          expect(buffer.string).to eq("foo\n")
        end
      end
    end
  end

  describe '#readlines' do
    let(:command) { 'seq 10' }

    context 'when limit is not provided' do
      it 'reads all lines' do
        expect(subject.readlines.count).to eq(10)
      end
    end

    context 'when limit is provided' do
      it 'reads the number of lines given' do
        expect(subject.readlines(3).count).to eq(3)
      end
    end
  end

  describe '#run' do
    it 'returns output of shell command' do
      expect(subject.run).to eq('foo')
    end

    context 'when chdir: is specified' do
      let(:command) { 'pwd' }
      let(:opts) { { chdir: tmp_directory } }

      it 'changes into the specified directory before executing' do
        expect(subject.run).to eq(tmp_directory)
      end
    end
  end

  describe '#try_run' do
    let(:command) { 'foo bar' }

    it 'returns empty string' do
      expect(subject.try_run).to eq('')
    end

    it 'does not raise error' do
      expect { subject.try_run }.not_to raise_error
    end

    context 'when chdir: is specified' do
      let(:command) { 'pwd' }
      let(:opts) { { chdir: tmp_directory } }

      it 'changes into the specified directory before executing' do
        expect(subject.try_run).to eq(tmp_directory)
      end
    end
  end

  describe '#read_stdout' do
    before do
      subject.run
    end

    it 'returns stdout of shell command' do
      expect(subject.read_stdout).to eq('foo')
    end
  end

  describe '#read_stderr' do
    let(:command) { 'echo error 1>&2; exit 1' }

    before do
      subject.run
    end

    it 'returns stdout of shell command' do
      expect(subject.read_stderr).to eq('error')
    end
  end

  describe '#success?' do
    describe '#run has not yet been executed' do
      it 'returns false' do
        expect(subject.success?).to be false
      end
    end

    describe '#run has been executed' do
      before do
        subject.run
      end

      context 'when command is successful' do
        it 'returns true' do
          expect(subject.success?).to be true
        end
      end

      context 'when command is not successful' do
        let(:command) { 'echo error 1>&2; exit 1' }

        it 'returns false' do
          expect(subject.success?).to be false
        end
      end
    end
  end
end
