# frozen_string_literal: true

require "values"

RSpec.describe MemoWise do
  describe "#memo_wise" do
    shared_examples "#memo_wise shared examples" do
      #
      # These examples depend on `let(:target)` -- can be an instance or a class
      #

      it "memoizes methods with no arguments" do
        expect(Array.new(4) { target.no_args }).to all eq("no_args")
        expect(target.no_args_counter).to eq(1)
      end

      it "memoizes methods with one positional argument" do
        expect(Array.new(4) { target.with_one_positional_arg(1) }).to all eq("with_one_positional_arg: a=1")
        expect(Array.new(4) { target.with_one_positional_arg(2) }).to all eq("with_one_positional_arg: a=2")

        # This should be executed once for each set of arguments passed
        expect(target.with_one_positional_arg_counter).to eq(2)
      end

      it "memoizes methods with positional arguments" do
        expect(Array.new(4) { target.with_positional_args(1, 2) }).to all eq("with_positional_args: a=1, b=2")
        expect(Array.new(4) { target.with_positional_args(1, 3) }).to all eq("with_positional_args: a=1, b=3")

        # This should be executed once for each set of arguments passed
        expect(target.with_positional_args_counter).to eq(2)
      end

      it "memoizes methods with positional and splat arguments" do
        expect(Array.new(4) { target.with_positional_and_splat_args(1, 2, 3) }).
          to all eq("with_positional_and_splat_args: a=1, args=[2, 3]")

        expect(Array.new(4) { target.with_positional_and_splat_args(1, 3, 4) }).
          to all eq("with_positional_and_splat_args: a=1, args=[3, 4]")

        # This should be executed once for each set of arguments passed
        expect(target.with_positional_and_splat_args_counter).to eq(2)
      end

      it "memoizes methods with one keyword argument" do
        expect(Array.new(4) { target.with_one_keyword_arg(a: 1) }).to all eq("with_one_keyword_arg: a=1")
        expect(Array.new(4) { target.with_one_keyword_arg(a: 2) }).to all eq("with_one_keyword_arg: a=2")

        # This should be executed once for each set of arguments passed
        expect(target.with_one_keyword_arg_counter).to eq(2)
      end

      it "memoizes methods with keyword arguments" do
        expect(Array.new(4) { target.with_keyword_args(a: 1, b: 2) }).to all eq("with_keyword_args: a=1, b=2")
        expect(Array.new(4) { target.with_keyword_args(a: 2, b: 3) }).to all eq("with_keyword_args: a=2, b=3")

        # This should be executed once for each set of arguments passed
        expect(target.with_keyword_args_counter).to eq(2)
      end

      it "memoizes methods with keyword and double-splat arguments" do
        expect(Array.new(4) { target.with_keyword_and_double_splat_args(a: 1, b: 2, c: 3) }).
          to all eq("with_keyword_and_double_splat_args: a=1, kwargs={:b=>2, :c=>3}")

        expect(Array.new(4) { target.with_keyword_and_double_splat_args(a: 1, b: 2, c: 4) }).
          to all eq("with_keyword_and_double_splat_args: a=1, kwargs={:b=>2, :c=>4}")

        # This should be executed once for each set of arguments passed
        expect(target.with_keyword_and_double_splat_args_counter).to eq(2)
      end

      it "memoizes methods with positional and keyword arguments" do
        expect(Array.new(4) { target.with_positional_and_keyword_args(1, b: 2) }).
          to all eq("with_positional_and_keyword_args: a=1, b=2")

        expect(Array.new(4) { target.with_positional_and_keyword_args(2, b: 3) }).
          to all eq("with_positional_and_keyword_args: a=2, b=3")

        # This should be executed once for each set of arguments passed
        expect(target.with_positional_and_keyword_args_counter).to eq(2)
      end

      it "memoizes methods with positional, splat, keyword, and double-splat arguments" do
        expect(Array.new(4) { target.with_positional_splat_keyword_and_double_splat_args(1, 2, 3, b: 4, c: 5, d: 6) }).
          to all eq("with_positional_splat_keyword_and_double_splat_args: a=1, args=[2, 3] b=4 kwargs={:c=>5, :d=>6}")

        expect(Array.new(4) { target.with_positional_splat_keyword_and_double_splat_args(1, 2, b: 4, c: 5) }).
          to all eq("with_positional_splat_keyword_and_double_splat_args: a=1, args=[2] b=4 kwargs={:c=>5}")

        # This should be executed once for each set of arguments passed
        expect(target.with_positional_splat_keyword_and_double_splat_args_counter).to eq(2)
      end

      it "memoizes methods with special characters in the name" do
        expect(Array.new(4) { target.special_chars? }).to all eq("special_chars?")
        expect(target.special_chars_counter).to eq(1)
      end

      it "memoizes methods set to false values" do
        expect(Array.new(4) { target.false_method }).to all eq(false)
        expect(target.false_method_counter).to eq(1)
      end

      it "memoizes methods set to nil values" do
        expect(Array.new(4) { target.nil_method }).to all eq(nil)
        expect(target.nil_method_counter).to eq(1)
      end

      context "with private methods" do
        it "keeps private methods private" do
          expect(target.private_methods).to include(:private_memowise_method)
        end

        it "memoizes private methods" do
          expect(Array.new(4) { target.send(:private_memowise_method) }).to all eq("private_memowise_method")
          expect(target.private_memowise_method_counter).to eq(1)
        end
      end

      context "with public methods" do
        it "keeps public methods public" do
          expect(target.public_methods).to include(:public_memowise_method)
        end

        it "memoizes public methods" do
          expect(Array.new(4) { target.public_memowise_method }).to all eq("public_memowise_method")
          expect(target.public_memowise_method_counter).to eq(1)
        end
      end

      it "memoizes methods with proc arguments" do
        proc_param = proc { true }
        expect(Array.new(4) { target.proc_method(proc_param) }).to all eq(true)

        expect(target.proc_method_counter).to eq(1)
      end

      it "will not memoize methods with implicit block arguments" do
        expect { target.implicit_block_method }.to raise_error(LocalJumpError)
      end

      it "will not memoize methods with explicit block arguments" do
        expect { target.explicit_block_method { nil } }.to raise_error(LocalJumpError)
      end
    end

    shared_examples "handles memoized/non-memoized methods with the same name at different "\
                    "scopes" do
      context "with non-memoized method with same name as memoized method" do
        context "when methods have no arguments" do
          it "does not memoize the non-memoized method" do
            # Confirm the memoized method works correctly first.
            expect(Array.new(4) { memoized.no_args }).to all eq("no_args")
            expect(memoized.no_args_counter).to eq(1)

            # Now confirm our non-memoized method is not memoized.
            expect(Array.new(4) { non_memoized.no_args }).to all eq("#{non_memoized_name}_no_args")
            expect(non_memoized.send("#{non_memoized_name}_no_args_counter")).to eq(4)
          end
        end

        context "when methods have one positional argument" do
          it "does not memoize the non-memoized method" do
            # Confirm the memoized method works correctly first.
            expect(Array.new(4) { memoized.with_one_positional_arg(1) }).to all eq("with_one_positional_arg: a=1")
            expect(memoized.with_one_positional_arg_counter).to eq(1)

            # Now confirm our non-memoized method is not memoized.
            expect(Array.new(4) { non_memoized.with_one_positional_arg(1) }).
              to all eq("#{non_memoized_name}_with_one_positional_arg: a=1")

            expect(non_memoized.send("#{non_memoized_name}_one_positional_arg_counter")).to eq(4)
          end
        end

        context "when methods have multiple positional arguments" do
          it "does not memoize the non-memoized method" do
            # Confirm the memoized method works correctly first.
            expect(Array.new(4) { memoized.with_positional_args(1, 2) }).to all eq("with_positional_args: a=1, b=2")
            expect(memoized.with_positional_args_counter).to eq(1)

            # Now confirm our non-memoized method is not memoized.
            expect(Array.new(4) { non_memoized.with_positional_args(1, 2) }).
              to all eq("#{non_memoized_name}_with_positional_args: a=1, b=2")

            expect(non_memoized.send("#{non_memoized_name}_positional_args_counter")).to eq(4)
          end
        end
      end
    end

    context "with instance methods" do
      include_context "with context for instance methods"

      # Use the instance as the target of "#memo_wise shared examples"
      let(:target) { instance }

      it_behaves_like "#memo_wise shared examples"

      it "does not memoize methods across instances" do
        instance2 = class_with_memo.new

        instance.no_args

        expect(instance.no_args_counter).to eq(1)
        expect(instance2.no_args_counter).to eq(0)
      end

      context "with protected methods" do
        it "keeps protected methods protected" do
          expect(target.protected_methods).to include(:protected_memowise_method)
        end

        it "memoizes protected methods" do
          expect(Array.new(4) { target.send(:protected_memowise_method) }).to all eq("protected_memowise_method")
          expect(target.protected_memowise_method_counter).to eq(1)
        end
      end

      context "when memo_wise has *not* been called on a *class* method" do
        it "does *not* create class-level instance variable" do
          expect(class_with_memo.instance_variables).not_to include(:@_memo_wise)
        end
      end

      # This test nondeterministically fails in JRuby with the following error:
      # NoMethodError:
      #   super: no superclass method `allocate' for #<Class:0xacc6a69>
      unless RUBY_PLATFORM == "java"
        context "when instances are created with Class#allocate" do
          let(:instance) { class_with_memo.allocate }

          it "memoizes correctly" do
            expect(Array.new(4) { instance.no_args }).to all eq("no_args")
            expect(instance.no_args_counter).to eq(1)
          end
        end
      end

      context "when the name of the method to memoize is not a symbol" do
        let(:class_with_memo) do
          super().tap { |klass| klass.memo_wise "no_args" }
        end

        it { expect { instance }.to raise_error(ArgumentError) }
      end

      it_behaves_like "handles memoized/non-memoized methods with the same name at different scopes" do
        let(:memoized) { instance }
        let(:non_memoized) { class_with_memo }
        let(:non_memoized_name) { :class }
      end

      context "when the class is a Value class using the 'values' gem" do
        let(:external_counter) { [0] }

        let(:increment_proc) { -> { external_counter[0] += 1 } }

        let(:value_class) do
          Value.new(:increment_proc) do
            prepend MemoWise # rubocop:disable RSpec/DescribedClass

            def no_args
              increment_proc.call
              "no_args"
            end
            memo_wise :no_args
          end
        end

        let(:value_instance) { value_class.new(increment_proc) }

        it "memoizes methods" do
          expect(Array.new(4) { value_instance.no_args }).to all eq("no_args")
          expect(external_counter[0]).to eq(1)
        end
      end

      context "when the class has a child class" do
        let(:child_class) do
          Class.new(class_with_memo) do
            def child_method_counter
              @child_method_counter || 0
            end

            def child_method
              @child_method_counter = child_method_counter + 1
              "child_method"
            end
          end
        end

        let(:instance) { child_class.new }

        it "memoizes the parent methods" do
          expect(Array.new(4) { instance.no_args }).to all eq("no_args")
          expect(instance.no_args_counter).to eq(1)
          expect(Array.new(4) { instance.child_method }).to all eq("child_method")
          expect(instance.child_method_counter).to eq(4)
        end

        context "when the child class also memoizes methods" do
          before :each do
            child_class.prepend described_class
            child_class.memo_wise :child_method
          end

          it "memoizes the parent and child methods separately" do
            expect(Array.new(4) { instance.no_args }).to all eq("no_args")
            expect(instance.no_args_counter).to eq(1)
            expect(Array.new(4) { instance.child_method }).to all eq("child_method")
            expect(instance.child_method_counter).to eq(1)
          end
        end
      end

      context "when the class inherits memoization from multiple modules" do
        let(:module1) do
          Module.new do
            prepend MemoWise

            def module1_method_counter
              @module1_method_counter || 0 # rubocop:disable RSpec/InstanceVariable
            end

            def module1_method
              @module1_method_counter = module1_method_counter + 1
              "module1_method"
            end
            memo_wise :module1_method
          end
        end

        let(:module2) do
          Module.new do
            prepend MemoWise

            def module2_method_counter
              @module2_method_counter || 0 # rubocop:disable RSpec/InstanceVariable
            end

            def module2_method
              @module2_method_counter = module2_method_counter + 1
              "module2_method"
            end
            memo_wise :module2_method
          end
        end

        let(:klass) do
          Class.new do
            include Module1, Module2
          end
        end

        let(:instance) { klass.new }

        before(:each) do
          stub_const("Module1", module1)
          stub_const("Module2", module2)
        end

        it "memoizes inherited methods separately" do
          expect(Array.new(4) { instance.module1_method }).to all eq("module1_method")
          expect(instance.module1_method_counter).to eq(1)
          expect(Array.new(4) { instance.module2_method }).to all eq("module2_method")
          expect(instance.module2_method_counter).to eq(1)
        end
      end

      context "when the class, its superclass, and its module all memoize methods" do
        let(:parent_class) do
          Class.new do
            prepend MemoWise

            def parent_class_method_counter
              @parent_class_method_counter || 0
            end

            def parent_class_method
              @parent_class_method_counter = parent_class_method_counter + 1
              "parent_class_method"
            end
            memo_wise :parent_class_method
          end
        end

        let(:module1) do
          Module.new do
            prepend MemoWise

            def module1_method_counter
              @module1_method_counter || 0 # rubocop:disable RSpec/InstanceVariable
            end

            def module1_method
              @module1_method_counter = module1_method_counter + 1
              "module1_method"
            end
            memo_wise :module1_method
          end
        end

        let(:child_class) do
          Class.new(parent_class) do
            include Module1

            def child_class_method_counter
              @child_class_method_counter || 0
            end

            def child_class_method
              @child_class_method_counter = child_class_method_counter + 1
              "child_class_method"
            end
            memo_wise :child_class_method
          end
        end

        let(:instance) { child_class.new }

        before(:each) do
          stub_const("Module1", module1)
        end

        it "memoizes inherited methods separately" do
          expect(Array.new(4) { instance.parent_class_method }).to all eq("parent_class_method")
          expect(instance.parent_class_method_counter).to eq(1)
          expect(Array.new(4) { instance.module1_method }).to all eq("module1_method")
          expect(instance.module1_method_counter).to eq(1)
          expect(Array.new(4) { instance.child_class_method }).to all eq("child_class_method")
          expect(instance.child_class_method_counter).to eq(1)
        end
      end
    end

    # These test cases would fail due to a JRuby bug
    # Skipping to make build pass until the bug is fixed
    # https://github.com/jruby/jruby/issues/6896
    unless RUBY_PLATFORM == "java"
      context "with class methods" do
        context "when defined with 'def self.'" do
          include_context "with context for class methods via 'def self.'"

          # Use the class as the target of "#memo_wise shared examples"
          let(:target) { class_with_memo }

          it_behaves_like "#memo_wise shared examples"

          it "creates a class-level instance variable" do
            # NOTE: test implementation detail to ensure the inverse test is valid
            expect(class_with_memo.instance_variables).to include(:@_memo_wise)
          end

          it_behaves_like "handles memoized/non-memoized methods with the same name at different scopes" do
            let(:memoized) { class_with_memo }
            let(:non_memoized) { class_with_memo.new }
            let(:non_memoized_name) { :instance }
          end

          context "when an invalid hash key is passed to .memo_wise" do
            let(:class_with_memo) do
              Class.new do
                prepend MemoWise

                def self.class_method; end
              end
            end

            it "raises an error when passing a key which is not `self:`" do
              expect { class_with_memo.send(:memo_wise, bad_key: :class_method) }.
                to raise_error(ArgumentError, "`:self` is the only key allowed in memo_wise")
            end
          end

          context "when the class has a child class" do
            let(:child_class) do
              Class.new(class_with_memo) do
                def self.child_method_counter
                  @child_method_counter || 0
                end

                def self.child_method
                  @child_method_counter = child_method_counter + 1
                  "child_method"
                end
              end
            end

            it "memoizes the parent methods" do
              expect(Array.new(4) { child_class.no_args }).to all eq("no_args")
              expect(child_class.no_args_counter).to eq(1)
              expect(Array.new(4) { child_class.child_method }).to all eq("child_method")
              expect(child_class.child_method_counter).to eq(4)
            end

            context "when the child class also memoizes methods" do
              before :each do
                child_class.prepend described_class
                child_class.memo_wise self: :child_method
              end

              it "memoizes the parent and child methods separately" do
                expect(Array.new(4) { child_class.no_args }).to all eq("no_args")
                expect(child_class.no_args_counter).to eq(1)
                expect(Array.new(4) { child_class.child_method }).to all eq("child_method")
                expect(child_class.child_method_counter).to eq(1)
              end
            end
          end
        end

        context "when defined with scope 'class << self'" do
          include_context "with context for class methods via scope 'class << self'"

          # Use the class as the target of "#memo_wise shared examples"
          let(:target) { class_with_memo }

          it_behaves_like "#memo_wise shared examples"

          it "creates a class-level instance variable" do
            # NOTE: this test ensure the inverse test above continues to be valid
            expect(class_with_memo.instance_variables).to include(:@_memo_wise)
          end

          it_behaves_like "handles memoized/non-memoized methods with the same name at different scopes" do
            let(:memoized) { class_with_memo }
            let(:non_memoized) { class_with_memo.new }
            let(:non_memoized_name) { :instance }
          end

          context "when the class has a child class" do
            let(:child_class) do
              Class.new(class_with_memo) do
                class << self
                  def child_method_counter
                    @child_method_counter || 0
                  end

                  def child_method
                    @child_method_counter = child_method_counter + 1
                    "child_method"
                  end
                end
              end
            end

            it "memoizes the parent methods" do
              expect(Array.new(4) { child_class.no_args }).to all eq("no_args")
              expect(child_class.no_args_counter).to eq(1)
              expect(Array.new(4) { child_class.child_method }).to all eq("child_method")
              expect(child_class.child_method_counter).to eq(4)
            end

            context "when the child class also memoizes methods" do
              let(:child_class) do
                Class.new(class_with_memo) do
                  class << self
                    prepend MemoWise

                    def child_method_counter
                      @child_method_counter || 0
                    end

                    def child_method
                      @child_method_counter = child_method_counter + 1
                      "child_method"
                    end
                    memo_wise :child_method
                  end
                end
              end

              it "memoizes the parent and child methods separately" do
                expect(Array.new(4) { child_class.no_args }).to all eq("no_args")
                expect(child_class.no_args_counter).to eq(1)
                expect(Array.new(4) { child_class.child_method }).to all eq("child_method")
                expect(child_class.child_method_counter).to eq(1)
              end
            end
          end
        end
      end
    end

    # These test cases would fail due to a JRuby bug
    # Skipping to make build pass until the bug is fixed
    # https://github.com/jruby/jruby/issues/6896
    unless RUBY_PLATFORM == "java"
      context "with module methods" do
        context "when defined with 'def self.'" do
          include_context "with context for module methods via 'def self.'"

          # Use the module as the target of "#memo_wise shared examples"
          let(:target) { module_with_memo }

          it_behaves_like "#memo_wise shared examples"

          it "creates a module-level instance variable" do
            # NOTE: test implementation detail to ensure the inverse test is valid
            expect(module_with_memo.instance_variables).to include(:@_memo_wise)
          end

          context "when an invalid hash key is passed to .memo_wise" do
            let(:module_with_memo) do
              Module.new do
                prepend MemoWise

                def self.module_method; end
              end
            end

            it "raises an error when passing a key which is not `self:`" do
              expect { module_with_memo.send(:memo_wise, bad_key: :module_method) }.
                to raise_error(ArgumentError, "`:self` is the only key allowed in memo_wise")
            end
          end
        end

        context "when defined with scope 'module << self'" do
          include_context "with context for module methods via scope 'class << self'"

          # Use the module as the target of "#memo_wise shared examples"
          let(:target) { module_with_memo }

          it_behaves_like "#memo_wise shared examples"

          it "creates a module-level instance variable" do
            # NOTE: this test ensure the inverse test above continues to be valid
            expect(module_with_memo.instance_variables).to include(:@_memo_wise)
          end
        end
      end
    end

    context "with module mixed into other classes" do
      context "when extended" do
        context "when defined with 'def'" do
          include_context "with context for module methods via normal scope"

          before(:each) { stub_const("ModuleWithMemo", module_with_memo) }

          let(:class_extending_module_with_memo) do
            Class.new do
              extend ModuleWithMemo
            end
          end

          let(:target) { class_extending_module_with_memo }

          it_behaves_like "#memo_wise shared examples"
        end

        context "when 1 module extended by 2 classes" do
          let(:module_with_memo) do
            Module.new do
              prepend MemoWise

              def test_method
                Random.rand
              end
              memo_wise :test_method
            end
          end
          let(:class_a_extending_module_with_memo) do
            Class.new do
              extend ModuleWithMemo
            end
          end
          let(:class_b_extending_module_with_memo) do
            Class.new do
              extend ModuleWithMemo
            end
          end

          before(:each) do
            stub_const("ModuleWithMemo", module_with_memo)
          end

          it "memoizes each extended class separately" do
            aggregate_failures do
              expect(class_a_extending_module_with_memo.test_method). # rubocop:disable RSpec/IdenticalEqualityAssertion
                to eq(class_a_extending_module_with_memo.test_method)
              expect(class_b_extending_module_with_memo.test_method). # rubocop:disable RSpec/IdenticalEqualityAssertion
                to eq(class_b_extending_module_with_memo.test_method)
              expect(class_a_extending_module_with_memo.test_method).
                to_not eq(class_b_extending_module_with_memo.test_method)
            end
          end
        end
      end

      context "when included" do
        context "when defined with 'def'" do
          include_context "with context for module methods via normal scope"

          before(:each) do
            stub_const("ModuleWithMemo", module_with_memo)
          end

          let(:class_including_module_with_memo) do
            Class.new do
              include ModuleWithMemo
            end
          end
          let(:instance) { class_including_module_with_memo.new }

          let(:target) { instance }

          it_behaves_like "#memo_wise shared examples"

          it_behaves_like "handles memoized/non-memoized methods with the same name at different scopes" do
            let(:memoized) { instance }
            let(:non_memoized) { module_with_memo }
            let(:non_memoized_name) { :module }
          end
        end

        # These test cases would fail due to a JRuby bug
        # Skipping to make build pass until the bug is fixed
        unless RUBY_PLATFORM == "java"
          context "when defined with 'def self.' and 'def'" do
            let(:module_with_memo) do
              Module.new do
                prepend MemoWise

                def self.test_method
                  Random.rand
                end
                memo_wise self: :test_method

                def test_method
                  Random.rand
                end
                memo_wise :test_method
              end
            end
            let(:class_including_module_with_memo) do
              Class.new do
                include ModuleWithMemo
              end
            end
            let(:instance) { class_including_module_with_memo.new }

            before(:each) do
              stub_const("ModuleWithMemo", module_with_memo)
            end

            it "memoizes instance and singleton methods separately" do
              aggregate_failures do
                expect(instance.test_method).to eq(instance.test_method) # rubocop:disable RSpec/IdenticalEqualityAssertion
                expect(module_with_memo.test_method).to eq(module_with_memo.test_method) # rubocop:disable RSpec/IdenticalEqualityAssertion
                expect(instance.test_method).to_not eq(module_with_memo.test_method)
              end
            end
          end
        end
      end

      context "when prepended" do
        context "when defined with 'def'" do
          include_context "with context for module methods via normal scope"

          before(:each) do
            stub_const("ModuleWithMemo", module_with_memo)
          end

          let(:class_prepending_module_with_memo) do
            Class.new do
              prepend ModuleWithMemo
            end
          end
          let(:instance) { class_prepending_module_with_memo.new }

          let(:target) { instance }

          it_behaves_like "#memo_wise shared examples"

          it_behaves_like "handles memoized/non-memoized methods with the same name at different scopes" do
            let(:memoized) { instance }
            let(:non_memoized) { module_with_memo }
            let(:non_memoized_name) { :module }
          end
        end
      end
    end
  end
end
