# frozen_string_literal: true

require 'spec_helper'

RSpec.describe CacheMarkdownField, :clean_gitlab_redis_cache, feature_category: :markdown do
  let(:ar_class) do
    Class.new(ActiveRecord::Base) do
      self.table_name = 'issues'
      include CacheMarkdownField
      cache_markdown_field :title, pipeline: :single_line
      cache_markdown_field :description

      before_validation -> { self.work_item_type_id = ::WorkItems::Type.default_issue_type.id }
    end
  end

  let(:ar_class_store_mentions_after_commit) do
    Class.new(ActiveRecord::Base) do
      self.table_name = 'issues'
      include CacheMarkdownField
      cache_markdown_field :description

      before_validation -> { self.work_item_type_id = ::WorkItems::Type.default_issue_type.id }

      def store_mentions_after_commit?
        true
      end

      def run_after_commit
        yield
      end
    end
  end

  let(:other_class) do
    Class.new do
      include CacheMarkdownField

      def initialize(args = {})
        @title = args[:title]
        @description = args[:description]
        @cached_markdown_version = args[:cached_markdown_version]
        @title_html = args[:title_html]
        @description_html = args[:description_html]
        @author = args[:author]
        @project = args[:project]
        @parent_user = args[:parent_user]
      end

      attr_accessor :title, :description, :cached_markdown_version

      cache_markdown_field :title, pipeline: :single_line
      cache_markdown_field :description

      def cache_key
        "cache-key"
      end
    end
  end

  let(:markdown) { '`Foo`' }
  let(:html) { '<p dir="auto"><code>Foo</code></p>' }

  let(:updated_markdown) { '`Bar`' }
  let(:updated_html) { '<p dir="auto"><code>Bar</code></p>' }

  let(:cache_version) { Gitlab::MarkdownCache::CACHE_COMMONMARK_VERSION_SHIFTED }

  def thing_subclass(klass, *extra_attributes)
    Class.new(klass) { attr_accessor(*extra_attributes) }
  end

  shared_examples 'a class with cached markdown fields' do
    describe '#cached_html_up_to_date?' do
      let(:thing) { klass.new(title: markdown, title_html: html, cached_markdown_version: cache_version) }

      subject { thing.cached_html_up_to_date?(:title) }

      it 'returns false when the version is absent' do
        thing.cached_markdown_version = nil

        is_expected.to be_falsy
      end

      it 'returns false when the version is too early' do
        thing.cached_markdown_version -= 1

        is_expected.to be_falsy
      end

      it 'returns false when the version is too late' do
        thing.cached_markdown_version += 1

        is_expected.to be_falsy
      end

      it 'returns false when the local version was bumped' do
        stub_application_setting(local_markdown_version: 2)
        thing.cached_markdown_version = cache_version

        is_expected.to be_falsy
      end

      it 'returns true when the local version is default' do
        thing.cached_markdown_version = cache_version

        is_expected.to be_truthy
      end

      it 'returns true when the cached version is just right' do
        stub_application_setting(local_markdown_version: 2)
        thing.cached_markdown_version = cache_version + 2

        is_expected.to be_truthy
      end
    end

    describe '#latest_cached_markdown_version' do
      let(:thing) { klass.new }

      subject { thing.latest_cached_markdown_version }

      it 'returns default version' do
        thing.cached_markdown_version = nil
        is_expected.to eq(cache_version)
      end
    end

    describe '#refresh_markdown_cache' do
      let(:thing) { klass.new(description: markdown, description_html: html, cached_markdown_version: cache_version) }

      before do
        stub_commonmark_sourcepos_disabled
        thing.description = updated_markdown
      end

      it 'fills all html fields' do
        thing.refresh_markdown_cache

        expect(thing.description_html).to eq(updated_html)
      end

      it 'does not save the result' do
        expect(thing).not_to receive(:save_markdown)

        thing.refresh_markdown_cache
      end

      it 'updates the markdown cache version' do
        thing.cached_markdown_version = nil
        thing.refresh_markdown_cache

        expect(thing.cached_markdown_version).to eq(cache_version)
      end
    end

    describe '#refresh_markdown_cache!' do
      let(:thing) { klass.new(description: markdown, description_html: html, cached_markdown_version: cache_version) }

      before do
        stub_commonmark_sourcepos_disabled
        thing.description = updated_markdown
      end

      it 'fills all html fields' do
        thing.refresh_markdown_cache!

        expect(thing.description_html).to eq(updated_html)
      end

      it 'saves the changes' do
        expect(thing)
          .to receive(:save_markdown)
          .with({ "description_html" => updated_html, "title_html" => "", "cached_markdown_version" => cache_version })

        thing.refresh_markdown_cache!
      end
    end

    describe '#banzai_render_context' do
      let(:thing) { klass.new(title: markdown, title_html: html, cached_markdown_version: cache_version) }

      subject(:context) { thing.banzai_render_context(:title) }

      it 'sets project to nil if the object lacks a project' do
        is_expected.to have_key(:project)
        expect(context[:project]).to be_nil
      end

      it 'excludes author if the object lacks an author' do
        is_expected.not_to have_key(:author)
      end

      it 'raises if the context for an unrecognised field is requested' do
        expect { thing.banzai_render_context(:not_found) }.to raise_error(ArgumentError)
      end

      it 'includes the pipeline' do
        title_context = thing.banzai_render_context(:title)

        expect(title_context[:pipeline]).to eq(:single_line)
      end

      it 'returns copies of the context template' do
        template = thing.cached_markdown_fields[:description]
        copy = thing.banzai_render_context(:description)

        expect(copy).not_to be(template)
      end

      context 'with a project' do
        let(:project) { build(:project, group: create(:group)) }
        let(:thing) { thing_subclass(klass, :project).new(title: markdown, title_html: html, project: project) }

        it 'sets the project in the context' do
          is_expected.to have_key(:project)
          expect(context[:project]).to eq(project)
        end
      end

      context 'with an author' do
        let(:user) { build(:user) }
        let(:thing) { thing_subclass(klass, :author).new(title: markdown, title_html: html, author: user) }

        it 'sets the author in the context' do
          is_expected.to have_key(:author)
          expect(context[:author]).to eq(user)
        end
      end

      context 'with a parent_user' do
        let(:user) { build(:user) }
        let(:thing) { thing_subclass(klass, :author, :parent_user).new(title: markdown, title_html: html, parent_user: user, author: user) }

        it 'sets the user in the context' do
          is_expected.to have_key(:user)
          expect(context[:user]).to eq(user)
        end

        context 'when the personal_snippet_reference_filters flag is disabled' do
          before do
            stub_feature_flags(personal_snippet_reference_filters: false)
          end

          it 'does not set the user in the context' do
            is_expected.not_to have_key(:user)
            expect(context[:user]).to be_nil
          end
        end
      end
    end

    describe '#updated_cached_html_for' do
      let(:thing) { klass.new(project_id: project.id, namespace_id: project.project_namespace_id, description: markdown, description_html: html, cached_markdown_version: cache_version) }

      context 'when the markdown cache is outdated' do
        before do
          thing.cached_markdown_version += 1
        end

        it 'calls #refresh_markdown_cache!' do
          expect(thing).to receive(:refresh_markdown_cache)

          expect(thing.updated_cached_html_for(:description)).to eq(html)
        end
      end

      context 'when the markdown field does not exist' do
        it 'returns nil' do
          expect(thing.updated_cached_html_for(:something)).to eq(nil)
        end
      end

      context 'when the markdown cache is up to date' do
        before do
          stub_commonmark_sourcepos_disabled
          thing.try(:save)
        end

        it 'does not call #refresh_markdown_cache!' do
          expect(thing).not_to receive(:refresh_markdown_cache!)

          expect(thing.updated_cached_html_for(:description)).to eq(html)
        end
      end
    end

    describe '#rendered_field_content' do
      let(:thing) { klass.new(description: markdown, description_html: nil, cached_markdown_version: cache_version) }

      context 'when a field can be cached' do
        it 'returns the html' do
          stub_commonmark_sourcepos_disabled
          thing.description = updated_markdown

          expect(thing.rendered_field_content(:description)).to eq updated_html
        end
      end

      context 'when a field cannot be cached' do
        it 'returns nil' do
          allow(thing).to receive(:can_cache_field?).with(:description).and_return false

          expect(thing.rendered_field_content(:description)).to eq nil
        end
      end
    end
  end

  shared_examples 'a class with mentionable markdown fields' do
    let(:mentionable) { klass.new(project_id: project.id, namespace_id: project.project_namespace_id, description: markdown, description_html: html, title: markdown, title_html: html, cached_markdown_version: cache_version) }

    context 'when klass is a Mentionable', :aggregate_failures do
      before do
        klass.send(:include, Mentionable)
        klass.send(:attr_mentionable, :description)
      end

      describe '#mentionable_attributes_changed?' do
        message = Struct.new(:text)

        let(:changes) do
          msg = message.new('test')

          changes = {}
          changes[msg] = ['', 'some message']
          changes[:random_sym_key] = ['', 'some message']
          changes["description"] = ['', 'some message']
          changes
        end

        it 'returns true with key string' do
          changes["description_html"] = ['', 'some message']

          allow(mentionable).to receive(:saved_changes).and_return(changes)

          expect(mentionable.send(:mentionable_attributes_changed?)).to be true
        end

        it 'returns false with key symbol' do
          changes[:description_html] = ['', 'some message']
          allow(mentionable).to receive(:saved_changes).and_return(changes)

          expect(mentionable.send(:mentionable_attributes_changed?)).to be false
        end

        it 'returns false when no attr_mentionable keys' do
          allow(mentionable).to receive(:saved_changes).and_return(changes)

          expect(mentionable.send(:mentionable_attributes_changed?)).to be false
        end
      end

      describe '#save' do
        context 'when cache is outdated' do
          before do
            stub_commonmark_sourcepos_disabled
            thing.cached_markdown_version += 1
          end

          context 'when the markdown field also a mentionable attribute' do
            let(:thing) { klass.new(project_id: project.id, namespace_id: project.project_namespace_id, description: markdown, description_html: html, cached_markdown_version: cache_version) }

            it 'calls #store_mentions!' do
              expect(thing).to receive(:mentionable_attributes_changed?).and_return(true)
              expect(thing).to receive(:store_mentions!)

              thing.try(:save)

              expect(thing.description_html).to eq(html)
            end
          end

          context 'when the markdown field is not mentionable attribute' do
            let(:thing) { klass.new(project_id: project.id, namespace_id: project.project_namespace_id, title: markdown, title_html: html, cached_markdown_version: cache_version) }

            it 'does not call #store_mentions!' do
              expect(thing).not_to receive(:store_mentions!)
              expect(thing).to receive(:refresh_markdown_cache)

              thing.try(:save)

              expect(thing.title_html).to eq(html)
            end
          end
        end

        context 'when the markdown field does not exist' do
          let(:thing) { klass.new(project_id: project.id, namespace_id: project.project_namespace_id, cached_markdown_version: cache_version) }

          it 'does not call #store_mentions!' do
            expect(thing).not_to receive(:store_mentions!)

            thing.try(:save)
          end
        end
      end
    end
  end

  context 'for Active record classes' do
    let_it_be(:project) { create(:project) }

    let(:klass) { ar_class }

    it_behaves_like 'a class with cached markdown fields'
    it_behaves_like 'a class with mentionable markdown fields'

    describe '#attribute_invalidated?' do
      let(:thing) { klass.create!(project_id: project.id, namespace_id: project.project_namespace_id, description: markdown, description_html: html, cached_markdown_version: cache_version) }

      it 'returns true when cached_markdown_version is different' do
        thing.cached_markdown_version += 1

        expect(thing.attribute_invalidated?(:description_html)).to eq(true)
      end

      it 'returns true when markdown is changed' do
        thing.description = updated_markdown

        expect(thing.attribute_invalidated?(:description_html)).to eq(true)
      end

      it 'returns true when both markdown and HTML are changed' do
        thing.description = updated_markdown
        thing.description_html = updated_html

        expect(thing.attribute_invalidated?(:description_html)).to eq(true)
      end

      it 'returns false when there are no changes' do
        expect(thing.attribute_invalidated?(:description_html)).to eq(false)
      end

      it 'returns false if skip_markdown_cache_validation is true' do
        # invalidates the attribute
        thing.cached_markdown_version += 1
        thing.description = updated_markdown

        thing.skip_markdown_cache_validation = true

        expect(thing.attribute_invalidated?(:description_html)).to eq(false)
      end
    end

    context 'when cache version is updated' do
      let(:old_version) { cache_version - 1 }
      let(:old_html) { '<p data-sourcepos="1:1-1:5" dir="auto" class="some-old-class"><code>Foo</code></p>' }

      let(:thing) do
        # This forces the record to have outdated HTML. We can't use `create` because the `before_create` hook
        # would re-render the HTML to the latest version
        klass.create!(project_id: project.id, namespace_id: project.project_namespace_id).tap do |thing|
          thing.update_columns(description: markdown, description_html: old_html, cached_markdown_version: old_version)
        end
      end

      it 'correctly updates cached HTML even if refresh_markdown_cache is called before updating the attribute' do
        stub_commonmark_sourcepos_disabled
        thing.refresh_markdown_cache

        thing.update!(description: updated_markdown)

        expect(thing.description_html).to eq(updated_html)
      end
    end
  end

  context 'for Active record classes that store mentions after commit' do
    let_it_be(:project) { create(:project) }

    let(:klass) { ar_class_store_mentions_after_commit }

    describe '#save' do
      context 'when cache is outdated' do
        before do
          stub_commonmark_sourcepos_disabled
          thing.cached_markdown_version += 1
        end

        context 'when the markdown field also a mentionable attribute' do
          let(:thing) { klass.new(project_id: project.id, namespace_id: project.project_namespace_id, description: markdown, description_html: html, cached_markdown_version: cache_version) }

          it 'calls #store_mentions! from #run_after_commit' do
            expect(thing).to receive(:mentionable_attributes_changed?).and_return(true)
            expect(thing).to receive(:run_after_commit).and_call_original
            expect(thing).to receive(:store_mentions!)

            thing.try(:save)

            expect(thing.description_html).to eq(html)
          end
        end
      end
    end
  end

  context 'for other classes' do
    let_it_be(:project) { create(:project) }

    let(:klass) { other_class }

    it_behaves_like 'a class with cached markdown fields'
  end
end
