# frozen_string_literal: true

require 'spec_helper'

RSpec.describe Suggestions::ApplyService, feature_category: :code_review_workflow do
  include ProjectForksHelper

  def build_position(**optional_args)
    args = { old_path: "files/ruby/popen.rb",
             new_path: "files/ruby/popen.rb",
             old_line: nil,
             new_line: 9,
             diff_refs: merge_request.diff_refs,
             **optional_args }

    Gitlab::Diff::Position.new(args)
  end

  def create_suggestion(args)
    position_args = args.slice(:old_path, :new_path, :old_line, :new_line)
    content_args = args.slice(:from_content, :to_content)

    position = build_position(**position_args)

    diff_note = create(
      :diff_note_on_merge_request,
      noteable: merge_request,
      position: position,
      project: project
    )

    suggestion_args = { note: diff_note }.merge(content_args)

    create(:suggestion, :content_from_repo, suggestion_args)
  end

  def apply(suggestions, custom_message = nil)
    result = apply_service.new(user, *suggestions, message: custom_message).execute

    suggestions.map(&:reload)

    expect(result[:status]).to eq(:success)
  end

  shared_examples 'successfully creates commit and updates suggestions' do
    it 'updates the files with the new content' do
      apply(suggestions)

      suggestions.each do |suggestion|
        path = suggestion.diff_file.file_path
        blob = project.repository.blob_at_branch(merge_request.source_branch, path)

        expect(blob.data).to eq(expected_content_by_path[path.to_sym])
      end
    end

    it 'updates suggestion applied and commit_id columns' do
      expect(suggestions.map(&:applied)).to all(be false)
      expect(suggestions.map(&:commit_id)).to all(be_nil)

      apply(suggestions)

      expect(suggestions.map(&:applied)).to all(be true)
      expect(suggestions.map(&:commit_id)).to all(be_present)
    end

    it 'created commit has users email and name' do
      apply(suggestions)

      commit = project.repository.commit

      expect(user.commit_email).not_to eq(user.email)
      expect(commit.author_email).to eq(user.commit_email)
      expect(commit.committer_email).to eq(user.commit_email)
      expect(commit.author_name).to eq(user.name)
      expect(commit.committer_name).to eq(user.name)
    end

    it 'tracks apply suggestion event' do
      expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
        .to receive(:track_apply_suggestion_action)
        .with(user: user, suggestions: suggestions)

      apply(suggestions)
    end

    context 'when a custom suggestion commit message' do
      before do
        project.update!(suggestion_commit_message: message)

        apply(suggestions)
      end

      context 'is not specified' do
        let(:message) { '' }

        it 'uses the default commit message' do
          message = project.repository.commit.message
          expect(message).to match(
            /Apply #{suggestions.size} suggestion\(s\) to \d+ file\(s\)/
          )

          author = suggestion.note.author
          expect(message).to include("Co-authored-by: #{author.name} <#{author.email}>")
        end
      end

      context 'is specified' do
        let(:message) do
          'refactor: %{project_name} %{branch_name} %{username}'
        end

        it 'generates a custom commit message' do
          expect(project.repository.commit.message).to(
            eq("refactor: Project_1 master test.user")
          )
        end
      end
    end

    context 'with a user suggested commit message' do
      let(:message) { "i'm a custom commit message!" }

      it "uses the user's commit message" do
        apply(suggestions, message)

        expect(project.repository.commit.message).to(eq(message))
      end
    end
  end

  subject(:apply_service) { described_class }

  let_it_be(:user) do
    create(:user, :commit_email, name: 'Test User', username: 'test.user')
  end

  let(:project) do
    create(:project, :repository, path: 'project-1', name: 'Project_1')
  end

  let(:merge_request) do
    create(:merge_request,
      source_project: project, target_project: project, source_branch: 'master')
  end

  let(:position) { build_position }

  let(:diff_note) do
    create(:diff_note_on_merge_request,
      noteable: merge_request, position: position, project: project)
  end

  let(:suggestion) do
    create(:suggestion, :content_from_repo,
      note: diff_note, to_content: "      raise RuntimeError, 'Explosion'\n      # explosion?\n")
  end

  let(:suggestion2) do
    create_suggestion(
      to_content: "      *** SUGGESTION CHANGE ***\n",
      new_line: 15)
  end

  let(:suggestion3) do
    create_suggestion(
      to_content: "      *** ANOTHER SUGGESTION CHANGE ***\n",
      old_path: "files/ruby/regex.rb",
      new_path: "files/ruby/regex.rb",
      new_line: 22)
  end

  let(:suggestions) { [suggestion, suggestion2, suggestion3] }

  context 'patch is appliable' do
    let(:popen_content) do
      <<-CONTENT.strip_heredoc
        require 'fileutils'
        require 'open3'

        module Popen
          extend self

          def popen(cmd, path=nil)
            unless cmd.is_a?(Array)
              raise RuntimeError, 'Explosion'
              # explosion?
            end

            path ||= Dir.pwd

            vars = {
              *** SUGGESTION CHANGE ***
            }

            options = {
              chdir: path
            }

            unless File.directory?(path)
              FileUtils.mkdir_p(path)
            end

            @cmd_output = ""
            @cmd_status = 0

            Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
              @cmd_output << stdout.read
              @cmd_output << stderr.read
              @cmd_status = wait_thr.value.exitstatus
            end

            return @cmd_output, @cmd_status
          end
        end
      CONTENT
    end

    let(:regex_content) do
      <<-CONTENT.strip_heredoc
        module Gitlab
          module Regex
            extend self

            def username_regex
              default_regex
            end

            def project_name_regex
              /\\A[a-zA-Z0-9][a-zA-Z0-9_\\-\\. ]*\\z/
            end

            def name_regex
              /\\A[a-zA-Z0-9_\\-\\. ]*\\z/
            end

            def path_regex
              default_regex
            end

            def archive_formats_regex
              *** ANOTHER SUGGESTION CHANGE ***
            end

            def git_reference_regex
              # Valid git ref regex, see:
              # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html
              %r{
                (?!
                   (?# doesn't begins with)
                   \\/|                    (?# rule #6)
                   (?# doesn't contain)
                   .*(?:
                      [\\\/.]\\\.|            (?# rule #1,3)
                      \\/\\/|               (?# rule #6)
                      @\\{|                (?# rule #8)
                      \\\\                  (?# rule #9)
                   )
                )
                [^\\000-\\040\\177~^:?*\\[]+  (?# rule #4-5)
                (?# doesn't end with)
                (?<!\\.lock)               (?# rule #1)
                (?<![\\/.])                (?# rule #6-7)
              }x
            end

            protected

            def default_regex
              /\\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\\-\\.]*(?<!\\.git)\\z/
            end
          end
        end
      CONTENT
    end

    let(:expected_content_by_path) do
      {
        "files/ruby/popen.rb": popen_content,
        "files/ruby/regex.rb": regex_content
      }
    end

    context 'non-fork project' do
      before do
        project.add_maintainer(user)
      end

      it_behaves_like 'successfully creates commit and updates suggestions'

      context 'when it fails to apply because a file was changed' do
        before do
          params = {
            file_path: suggestion3.diff_file.file_path,
            start_branch: suggestion3.branch,
            branch_name: suggestion3.branch,
            commit_message: 'Update file',
            file_content: 'New content'
          }

          # Reload the suggestion so it's memoized values get reset after the
          # file was changed.
          suggestion3.reload

          Files::UpdateService.new(project, user, params).execute
        end

        it 'returns error message' do
          result = apply_service.new(user, suggestion, suggestion3, suggestion2).execute

          expect(result).to eq(message: 'A file has been changed.', status: :error)
        end
      end

      context 'when HEAD from position is different from source branch HEAD on repo' do
        it 'returns error message' do
          allow(suggestion).to receive(:appliable?).and_return(true)
          allow(suggestion.position).to receive(:head_sha).and_return('old-sha')
          allow(suggestion.noteable).to receive(:source_branch_sha).and_return('new-sha')

          result = apply_service.new(user, suggestion).execute

          expect(result).to eq(message: 'A file has been changed.', status: :error)
        end
      end

      context 'single suggestion' do
        let(:author) { suggestions.first.note.author }
        let(:commit) { project.repository.commit }

        context 'author of suggestion applies suggestion' do
          before do
            suggestion.note.update!(author_id: user.id)

            apply(suggestions)
          end

          it 'created commit by same author and committer' do
            expect(user.commit_email).to eq(author.commit_email_or_default)
            expect(author).to eq(user)
            expect(commit.author_email).to eq(author.commit_email_or_default)
            expect(commit.committer_email).to eq(user.commit_email)
            expect(commit.author_name).to eq(author.name)
            expect(commit.committer_name).to eq(user.name)
          end
        end

        context 'another user applies suggestion' do
          before do
            apply(suggestions)
          end

          it 'created commit has authors info and commiters info' do
            expect(user.commit_email).not_to eq(user.email)
            expect(author).not_to eq(user)
            expect(commit.author_email).to eq(user.commit_email)
            expect(commit.committer_email).to eq(user.commit_email)
            expect(commit.author_name).to eq(user.name)
            expect(commit.committer_name).to eq(user.name)
          end
        end
      end

      context 'multiple suggestions' do
        let(:author_emails) { suggestions.map { |s| s.note.author.commit_email_or_default } }
        let(:first_author) { suggestion.note.author }
        let(:commit) { project.repository.commit }

        context 'when all the same author' do
          before do
            apply(suggestions)
          end

          it 'uses first authors information' do
            expect(author_emails).to include(first_author.commit_email_or_default).exactly(3)
            expect(commit.author_email).to eq(user.commit_email)
          end
        end

        context 'when all different authors' do
          before do
            suggestion2.note.update!(author_id: create(:user).id)
            suggestion3.note.update!(author_id: create(:user).id)
            apply(suggestions)
          end

          it 'uses committers information' do
            expect(commit.author_email).to eq(user.commit_email)
            expect(commit.committer_email).to eq(user.commit_email)
          end
        end
      end

      context 'multiple suggestions applied sequentially' do
        def apply_suggestion(suggestion)
          suggestion.reload
          merge_request.reload
          merge_request.clear_memoized_shas

          result = apply_service.new(user, suggestion).execute
          suggestion.reload
          expect(result[:status]).to eq(:success)

          refresh = MergeRequests::RefreshService.new(project: project, current_user: user)
          refresh.execute(
            merge_request.diff_head_sha,
            suggestion.commit_id,
            merge_request.source_branch_ref
          )

          result
        end

        def fetch_raw_diff(suggestion)
          project.reload.commit(suggestion.commit_id)
            .diffs.diff_files.first.diff.diff
        end

        it 'applies multiple suggestions in subsequent versions correctly' do
          suggestion1 = create_suggestion(
            from_content: "\n",
            to_content: "# v1 change\n",
            old_line: nil,
            new_line: 13)

          suggestion2 = create_suggestion(
            from_content: "      @cmd_output << stderr.read\n",
            to_content: "# v2 change\n",
            old_line: 24,
            new_line: 31)

          apply_suggestion(suggestion1)
          apply_suggestion(suggestion2)

          suggestion1_diff = fetch_raw_diff(suggestion1)
          suggestion2_diff = fetch_raw_diff(suggestion2)

          expected_suggestion1_diff = <<-CONTENT.strip_heredoc
            @@ -10,7 +10,7 @@ module Popen
                 end
            #{' '}
                 path ||= Dir.pwd
            -
            +# v1 change
                 vars = {
                   "PWD" => path
                 }
          CONTENT
          expected_suggestion2_diff = <<-CONTENT.strip_heredoc
            @@ -28,7 +28,7 @@ module Popen
            #{' '}
                 Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
                   @cmd_output << stdout.read
            -      @cmd_output << stderr.read
            +# v2 change
                   @cmd_status = wait_thr.value.exitstatus
                 end
          CONTENT
          expect(suggestion1_diff.strip).to eq(expected_suggestion1_diff.strip)
          expect(suggestion2_diff.strip).to eq(expected_suggestion2_diff.strip)
        end
      end

      context 'multi-line suggestion' do
        let(:popen_content) do
          <<~CONTENT.strip_heredoc
            require 'fileutils'
            require 'open3'

            module Popen
              extend self

            # multi
            # line

                vars = {
                  "PWD" => path
                }

                options = {
                  chdir: path
                }

                unless File.directory?(path)
                  FileUtils.mkdir_p(path)
                end

                @cmd_output = ""
                @cmd_status = 0

                Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
                  @cmd_output << stdout.read
                  @cmd_output << stderr.read
                  @cmd_status = wait_thr.value.exitstatus
                end

                return @cmd_output, @cmd_status
              end
            end
          CONTENT
        end

        let(:expected_content_by_path) do
          {
            "files/ruby/popen.rb": popen_content
          }
        end

        let(:suggestion) do
          create(:suggestion, :content_from_repo,
            note: diff_note, lines_above: 2, lines_below: 3, to_content: "# multi\n# line\n")
        end

        let(:suggestions) { [suggestion] }

        it_behaves_like 'successfully creates commit and updates suggestions'
      end

      context 'remove an empty line suggestion' do
        let(:popen_content) do
          <<~CONTENT.strip_heredoc
            require 'fileutils'
            require 'open3'

            module Popen
              extend self

              def popen(cmd, path=nil)
                unless cmd.is_a?(Array)
                  raise RuntimeError, "System commands must be given as an array of strings"
                end

                path ||= Dir.pwd
                vars = {
                  "PWD" => path
                }

                options = {
                  chdir: path
                }

                unless File.directory?(path)
                  FileUtils.mkdir_p(path)
                end

                @cmd_output = ""
                @cmd_status = 0

                Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
                  @cmd_output << stdout.read
                  @cmd_output << stderr.read
                  @cmd_status = wait_thr.value.exitstatus
                end

                return @cmd_output, @cmd_status
              end
            end
          CONTENT
        end

        let(:expected_content_by_path) do
          {
            "files/ruby/popen.rb": popen_content
          }
        end

        let(:suggestion) do
          create_suggestion(to_content: "", new_line: 13)
        end

        let(:suggestions) { [suggestion] }

        it_behaves_like 'successfully creates commit and updates suggestions'
      end
    end

    context 'fork-project' do
      let(:project) { create(:project, :public, :repository) }

      let(:forked_project) do
        fork_project_with_submodules(project, user)
      end

      let(:merge_request) do
        create(
          :merge_request,
          source_branch: 'conflict-resolvable-fork',
          source_project: forked_project,
          target_branch: 'conflict-start',
          target_project: project
        )
      end

      let!(:diff_note) do
        create(
          :diff_note_on_merge_request,
          noteable: merge_request,
          position: position,
          project: project
        )
      end

      before do
        project.add_maintainer(user)
      end

      it 'updates file in the source project' do
        expect(Files::MultiService).to receive(:new)
          .with(merge_request.source_project, user, anything)
          .and_call_original

        apply_service.new(user, suggestion).execute
      end
    end
  end

  context 'no permission' do
    let(:merge_request) do
      create(:merge_request, source_project: project, target_project: project)
    end

    let(:diff_note) do
      create(:diff_note_on_merge_request,
        noteable: merge_request, position: position, project: project)
    end

    context 'user cannot write in project repo' do
      before do
        project.add_reporter(user)
      end

      it 'returns error' do
        result = apply_service.new(user, suggestion).execute

        expect(result).to eq(
          message: "You are not allowed to push into this branch",
          status: :error
        )
      end
    end
  end

  context 'patch is not appliable' do
    let(:merge_request) do
      create(:merge_request, source_project: project, target_project: project)
    end

    let(:diff_note) do
      create(:diff_note_on_merge_request,
        noteable: merge_request, position: position, project: project)
    end

    before do
      project.add_maintainer(user)
    end

    shared_examples_for 'service not tracking apply suggestion event' do
      it 'does not track apply suggestion event' do
        expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
          .not_to receive(:track_apply_suggestion_action)

        result
      end
    end

    context 'diff file was not found' do
      let(:result) { apply_service.new(user, suggestion).execute }

      before do
        expect(suggestion.note).to receive(:latest_diff_file).and_return(nil)
      end

      it 'returns error message' do
        expect(result).to eq(message: 'A file was not found.', status: :error)
      end

      it_behaves_like 'service not tracking apply suggestion event'
    end

    context 'when not all suggestions belong to the same branch' do
      let(:merge_request2) do
        create(
          :merge_request,
          :conflict,
          source_project: project,
          target_project: project
        )
      end

      let(:position2) do
        Gitlab::Diff::Position.new(
          old_path: "files/ruby/popen.rb",
          new_path: "files/ruby/popen.rb",
          old_line: nil,
          new_line: 15,
          diff_refs: merge_request2.diff_refs
        )
      end

      let(:diff_note2) do
        create(
          :diff_note_on_merge_request,
          noteable: merge_request2,
          position: position2,
          project: project
        )
      end

      let(:other_branch_suggestion) { create(:suggestion, note: diff_note2) }
      let(:result) { apply_service.new(user, suggestion, other_branch_suggestion).execute }

      it 'renders error message' do
        expect(result).to eq(
          message: 'Suggestions must all be on the same branch.',
          status: :error
        )
      end

      it_behaves_like 'service not tracking apply suggestion event'
    end

    context 'suggestion is not appliable' do
      let(:inapplicable_reason) { "Can't apply this suggestion." }
      let(:result) { apply_service.new(user, suggestion).execute }

      before do
        expect(suggestion).to receive(:appliable?).and_return(false)
        expect(suggestion).to receive(:inapplicable_reason).and_return(inapplicable_reason)
      end

      it 'returns error message' do
        expect(result).to eq(message: inapplicable_reason, status: :error)
      end

      it_behaves_like 'service not tracking apply suggestion event'
    end

    context 'lines of suggestions overlap' do
      let(:suggestion) do
        create_suggestion(
          to_content: "      raise RuntimeError, 'Explosion'\n      # explosion?\n")
      end

      let(:overlapping_suggestion) do
        create_suggestion(to_content: "I Overlap!")
      end

      let(:result) { apply_service.new(user, suggestion, overlapping_suggestion).execute }

      it 'returns error message' do
        expect(result).to eq(
          message: 'Suggestions are not applicable as their lines cannot overlap.',
          status: :error
        )
      end

      it_behaves_like 'service not tracking apply suggestion event'
    end
  end
end
