# frozen_string_literal: true

require 'spec_helper'
require 'git/command_line/capturing'

RSpec.describe Git::CommandLine::Capturing do
  let(:env) { {} }
  let(:binary_path) { 'git' }
  let(:global_opts) { [] }
  let(:logger) { Logger.new(nil) }
  let(:described_instance) { described_class.new(env, binary_path, global_opts, logger) }

  describe '#initialize' do
    subject(:instance) { described_instance }

    it 'stores all constructor arguments' do
      expect(instance).to have_attributes(
        env: env,
        binary_path: binary_path,
        global_opts: global_opts,
        logger: logger
      )
    end
  end

  # Builds a mock ProcessExecuter::ResultWithCapture.
  # Plain double: ProcessExecuter result classes delegate to Process::Status via
  # SimpleDelegator/method_missing, so instance_double cannot verify the delegated
  # interface (signaled?, exitstatus, etc.).
  def mock_result(**overrides)
    defaults = {
      command: %w[git version],
      stdout: '',
      stderr: '',
      success?: true,
      signaled?: false,
      timed_out?: false
    }
    double('ProcessExecuter::ResultWithCapture', defaults.merge(overrides))
  end

  describe '#run' do
    subject(:result) { described_instance.run(*run_args, **run_opts) }

    let(:run_args) { ['version'] }
    let(:run_opts) { {} }

    context 'with no options' do
      before do
        mocked = mock_result(command: %w[git version], stdout: "git version 2.39.1\n")
        allow(ProcessExecuter).to receive(:run_with_capture).and_return(mocked)
      end

      it 'returns a result with the captured output' do
        expect(result).to be_a(Git::CommandLine::Result)
        expect(result.stdout).to eq("git version 2.39.1\n")
        expect(result.stderr).to eq('')
      end
    end

    context 'with chomp: true' do
      let(:run_opts) { { chomp: true } }

      before do
        mocked = mock_result(command: %w[git version], stdout: "git version 2.39.1\n", stderr: "warning\n")
        allow(ProcessExecuter).to receive(:run_with_capture).and_return(mocked)
      end

      it 'chomps stdout and stderr' do
        expect(result.stdout).to eq('git version 2.39.1')
        expect(result.stderr).to eq('warning')
      end
    end

    context 'with a logger' do
      let(:log_output) { StringIO.new }
      let(:logger) { Logger.new(log_output, level: Logger::DEBUG) }

      before do
        mocked = mock_result(
          command: %w[git status],
          stdout: "modified: foo.rb\n",
          stderr: "warning: bar\n"
        )
        allow(ProcessExecuter).to receive(:run_with_capture).and_return(mocked)
      end

      it 'logs the command and its exit status at INFO level' do
        described_instance.run('status')
        expect(log_output.string).to match(/^I, .*\["git", "status"\].*exited with status/)
      end

      it 'logs the inspected stdout and stderr at DEBUG level' do
        described_instance.run('status')
        expect(log_output.string).to match(/^D, .*stdout:\n"modified: foo\.rb\\n"\nstderr:\n"warning: bar\\n"/m)
      end
    end

    context 'with normalize: false (the default)' do
      let(:run_opts) { { normalize: false } }

      before do
        # A Latin-1 (ISO-8859-1) byte sequence that is not valid UTF-8.
        # With normalize: false, the bytes must be returned unchanged.
        latin1_out = "caf\xE9 output\n".dup.force_encoding(Encoding::ISO_8859_1)
        mocked = mock_result(command: %w[git version], stdout: latin1_out, stderr: '')
        allow(ProcessExecuter).to receive(:run_with_capture).and_return(mocked)
      end

      it 'preserves raw non-UTF-8 stdout bytes unchanged' do
        expect(result.stdout.bytes).to eq("caf\xE9 output\n".bytes)
      end
    end

    context 'with normalize: true' do
      let(:run_opts) { { normalize: true } }

      context 'when output is already valid UTF-8' do
        before do
          mocked = mock_result(command: %w[git version], stdout: "git version 2.39.1\n", stderr: "valid utf-8\n")
          allow(ProcessExecuter).to receive(:run_with_capture).and_return(mocked)
        end

        it 'preserves stdout unchanged' do
          expect(result.stdout).to eq("git version 2.39.1\n")
        end

        it 'preserves stderr unchanged' do
          expect(result.stderr).to eq("valid utf-8\n")
        end
      end

      context 'when output is valid but non-UTF-8 encoded' do
        before do
          # A Latin-1 (ISO-8859-1) string with a valid encoding but not UTF-8.
          # normalize_encoding re-encodes it to UTF-8.
          latin1_out = "caf\xE9 output\n".dup.force_encoding(Encoding::ISO_8859_1)
          latin1_err = "caf\xE9 warning\n".dup.force_encoding(Encoding::ISO_8859_1)
          mocked = mock_result(command: %w[git version], stdout: latin1_out, stderr: latin1_err)
          allow(ProcessExecuter).to receive(:run_with_capture).and_return(mocked)
        end

        it 'converts stdout to valid UTF-8' do
          expect(result.stdout).to eq("caf\u00E9 output\n")
          expect(result.stdout.encoding).to eq(Encoding::UTF_8)
        end

        it 'converts stderr to valid UTF-8' do
          expect(result.stderr).to eq("caf\u00E9 warning\n")
          expect(result.stderr.encoding).to eq(Encoding::UTF_8)
        end
      end
    end

    context 'with merge: true' do
      let(:run_opts) { { merge: true } }

      it 'forwards merge_output: true to ProcessExecuter' do
        expect(ProcessExecuter).to receive(:run_with_capture) do |*_env_and_cmd, **opts|
          expect(opts[:merge_output]).to be true
          mock_result(command: %w[git version])
        end
        result
      end
    end

    context 'with chdir: option' do
      let(:run_opts) { { chdir: '/tmp' } }

      it 'forwards chdir to ProcessExecuter' do
        expect(ProcessExecuter).to receive(:run_with_capture) do |*_env_and_cmd, **opts|
          expect(opts[:chdir]).to eq('/tmp')
          mock_result(command: %w[git version])
        end
        result
      end
    end

    context 'with timeout: option' do
      let(:run_opts) { { timeout: 5 } }

      it 'forwards timeout as timeout_after: to ProcessExecuter' do
        expect(ProcessExecuter).to receive(:run_with_capture) do |*_env_and_cmd, **opts|
          expect(opts[:timeout_after]).to eq(5)
          mock_result(command: %w[git version])
        end
        result
      end
    end

    context 'with out: option' do
      let(:out_io) { StringIO.new }
      let(:run_opts) { { out: out_io } }

      it 'forwards out: to ProcessExecuter' do
        expect(ProcessExecuter).to receive(:run_with_capture) do |*_env_and_cmd, **opts|
          expect(opts[:out]).to be(out_io)
          mock_result(command: %w[git version])
        end
        result
      end
    end

    context 'with err: option' do
      let(:err_io) { StringIO.new }
      let(:run_opts) { { err: err_io } }

      it 'forwards err: to ProcessExecuter' do
        expect(ProcessExecuter).to receive(:run_with_capture) do |*_env_and_cmd, **opts|
          expect(opts[:err]).to be(err_io)
          mock_result(command: %w[git version])
        end
        result
      end
    end

    context 'with in: option' do
      let(:in_io) { StringIO.new }
      let(:run_opts) { { in: in_io } }

      it 'forwards in: to ProcessExecuter' do
        expect(ProcessExecuter).to receive(:run_with_capture) do |*_env_and_cmd, **opts|
          expect(opts[:in]).to be(in_io)
          mock_result(command: %w[git version])
        end
        result
      end
    end

    context 'with env: option' do
      let(:run_opts) { { env: { 'GIT_DIR' => '/tmp/repo' } } }

      it 'merges the per-call env into the instance env and passes it as the first argument' do
        expect(ProcessExecuter).to receive(:run_with_capture) do |env_arg, *_cmd, **_opts|
          expect(env_arg).to include('GIT_DIR' => '/tmp/repo')
          mock_result(command: %w[git version])
        end
        result
      end
    end

    context 'with raise_on_failure: true (default)' do
      before do
        mocked = mock_result(
          command: %w[git status],
          stdout: "Changes to be committed:\n  modified: foo.rb\n",
          stderr: 'fatal: not a git repository',
          success?: false,
          exitstatus: 1
        )
        allow(ProcessExecuter).to receive(:run_with_capture).and_return(mocked)
      end

      it 'raises FailedError when success? is false' do
        expect { described_instance.run('status') }
          .to raise_error(Git::FailedError, /git.*status/)
      end

      it 'attaches the result to the error' do
        expect { described_instance.run('status') }
          .to raise_error(Git::FailedError, /git.*status/) do |error|
            expect(error.result.status.success?).to be false
            expect(error.result.status.exitstatus).to eq(1)
            expect(error.result.stdout).to eq("Changes to be committed:\n  modified: foo.rb\n")
            expect(error.result.stderr).to eq('fatal: not a git repository')
          end
      end
    end

    context 'with raise_on_failure: false' do
      let(:run_args) { ['status'] }
      let(:run_opts) { { raise_on_failure: false } }

      before do
        mocked = mock_result(
          command: %w[git status],
          stdout: "Changes to be committed:\n  modified: foo.rb\n",
          stderr: 'fatal: not a git repository',
          success?: false,
          exitstatus: 1
        )
        allow(ProcessExecuter).to receive(:run_with_capture).and_return(mocked)
      end

      it 'returns a CommandLineResult without raising when success? is false' do
        expect(result).to be_a(Git::CommandLine::Result)
        expect(result.status.success?).to be false
        expect(result.status.exitstatus).to eq(1)
        expect(result.stdout).to eq("Changes to be committed:\n  modified: foo.rb\n")
        expect(result.stderr).to eq('fatal: not a git repository')
      end
    end

    context 'when command times out' do
      before do
        mocked = mock_result(command: %w[git status], success?: false, timed_out?: true)
        allow(ProcessExecuter).to receive(:run_with_capture).and_return(mocked)
      end

      it 'raises TimeoutError regardless of raise_on_failure' do
        expect { described_instance.run('status', raise_on_failure: false, timeout: 1) }
          .to raise_error(Git::TimeoutError, /timed out after/)
      end

      it 'attaches the result and timeout_duration to the error' do
        expect { described_instance.run('status', raise_on_failure: false, timeout: 1) }
          .to raise_error(Git::TimeoutError, /timed out after/) do |error|
            expect(error.result.status.timed_out?).to be true
            expect(error.timeout_duration).to eq(1)
          end
      end
    end

    context 'when command is signaled' do
      before do
        mocked = mock_result(command: %w[git status], success?: false, signaled?: true)
        allow(ProcessExecuter).to receive(:run_with_capture).and_return(mocked)
      end

      it 'raises SignaledError regardless of raise_on_failure' do
        expect { described_instance.run('status', raise_on_failure: false) }
          .to raise_error(Git::SignaledError, /git.*status/)
      end

      it 'attaches the result to the error' do
        expect { described_instance.run('status', raise_on_failure: false) }
          .to raise_error(Git::SignaledError, /git.*status/) do |error|
            expect(error.result.status.signaled?).to be true
          end
      end
    end

    context 'with an array element in the args list' do
      it 'raises ArgumentError with a descriptive message' do
        expect { described_instance.run([%w[bad arg]]) }
          .to raise_error(ArgumentError, /can not contain an array/)
      end
    end

    context 'with an unknown option' do
      it 'raises ArgumentError naming the unknown key' do
        expect { described_instance.run('status', unknown_option: true) }
          .to raise_error(ArgumentError, /Unknown options: unknown_option/)
      end
    end

    context 'when ProcessExecuter raises ArgumentError' do
      let(:pe_error) { ProcessExecuter::ArgumentError.new('bad spawn arg') }

      before do
        allow(ProcessExecuter).to receive(:run_with_capture).and_raise(pe_error)
      end

      it 'translates to ArgumentError' do
        expect { described_instance.run('version') }
          .to raise_error(ArgumentError, 'bad spawn arg')
      end

      it 'sets the ProcessExecuter::ArgumentError as the cause' do
        expect { described_instance.run('version') }
          .to raise_error(ArgumentError, 'bad spawn arg') do |error|
            expect(error.cause).to be(pe_error)
          end
      end
    end

    context 'when ProcessExecuter raises ProcessIOError' do
      let(:original_error) { IOError.new('underlying io problem') }

      before do
        # Build a ProcessIOError that wraps original_error as its cause, mirroring
        # how ProcessExecuter raises it in production.
        pe_error = begin
          raise original_error
        rescue StandardError
          begin
            raise ProcessExecuter::ProcessIOError, 'pipe failure'
          rescue StandardError => e
            e
          end
        end
        allow(ProcessExecuter).to receive(:run_with_capture).and_raise(pe_error)
      end

      it 'translates to Git::ProcessIOError' do
        expect { described_instance.run('version') }
          .to raise_error(Git::ProcessIOError, /pipe failure/)
      end

      it 'sets the original IO error as the cause' do
        # TruffleRuby < 25.0.0 has a bug where the cause is not set correctly
        # when an error is raised inside a rescue block.
        # See: https://github.com/truffleruby/truffleruby/issues/3831
        skip 'TruffleRuby < 25.0.0 does not correctly set error cause in rescue blocks' \
          if RUBY_ENGINE == 'truffleruby' && Gem::Version.new(RUBY_ENGINE_VERSION) < Gem::Version.new('25.0.0')

        expect { described_instance.run('version') }
          .to raise_error(Git::ProcessIOError, /pipe failure/) do |error|
            expect(error.cause).to be(original_error)
          end
      end
    end

    context 'when ProcessExecuter raises Errno::ESRCH (timeout race)' do
      let(:original_error) { Errno::ESRCH.new('No such process') }

      before do
        allow(ProcessExecuter).to receive(:run_with_capture).and_raise(original_error)
      end

      it 'translates to Git::ProcessIOError' do
        expect { described_instance.run('version') }
          .to raise_error(Git::ProcessIOError, /timeout race/)
      end

      it 'sets the Errno::ESRCH as the cause' do
        expect { described_instance.run('version') }
          .to raise_error(Git::ProcessIOError, /timeout race/) do |error|
            expect(error.cause).to be(original_error)
          end
      end
    end
  end
end
