# frozen_string_literal: true

require "timecop"
require "statesman/adapters/shared_examples"
require "statesman/exceptions"

describe Statesman::Adapters::ActiveRecord, :active_record do
  before do
    prepare_model_table
    prepare_transitions_table

    prepare_sti_model_table
    prepare_sti_transitions_table

    Statesman.configure do
      # Rubocop requires described_class to be used, but this block
      # is instance_eval'd and described_class won't be defined
      # rubocop:disable RSpec/DescribedClass
      storage_adapter(Statesman::Adapters::ActiveRecord)
      # rubocop:enable RSpec/DescribedClass
    end
  end

  after { Statesman.configure { storage_adapter(Statesman::Adapters::Memory) } }

  let(:model_class) { MyActiveRecordModel }
  let(:transition_class) { MyActiveRecordModelTransition }
  let(:observer) { double(Statesman::Machine, execute: nil) }
  let(:model) { model_class.create(current_state: :pending) }

  it_behaves_like "an adapter", described_class, MyActiveRecordModelTransition

  describe "#initialize" do
    context "with unserialized metadata and non json column type" do
      before do
        metadata_column = double
        allow(metadata_column).to receive_messages(sql_type: "")
        allow(MyActiveRecordModelTransition).
          to receive_messages(columns_hash: { "metadata" => metadata_column })
        expect(MyActiveRecordModelTransition).
          to receive(:type_for_attribute).with("metadata").
          and_return(ActiveRecord::Type::Value.new)
      end

      it "raises an exception" do
        expect do
          described_class.new(MyActiveRecordModelTransition,
                              MyActiveRecordModel, observer)
        end.to raise_exception(Statesman::UnserializedMetadataError)
      end
    end

    context "with serialized metadata and json column type" do
      before do
        metadata_column = double
        allow(metadata_column).to receive_messages(sql_type: "json")
        allow(MyActiveRecordModelTransition).to receive_messages(columns_hash:
                                           { "metadata" => metadata_column })
        if ActiveRecord.respond_to?(:gem_version) &&
            ActiveRecord.gem_version >= Gem::Version.new("4.2.0.a")
          serialized_type = ActiveRecord::Type::Serialized.new(
            "", ActiveRecord::Coders::JSON
          )
          expect(MyActiveRecordModelTransition).
            to receive(:type_for_attribute).with("metadata").
            and_return(serialized_type)
        else
          expect(MyActiveRecordModelTransition).
            to receive_messages(serialized_attributes: { "metadata" => "" })
        end
      end

      it "raises an exception" do
        expect do
          described_class.new(MyActiveRecordModelTransition,
                              MyActiveRecordModel, observer)
        end.to raise_exception(Statesman::IncompatibleSerializationError)
      end
    end

    context "with serialized metadata and jsonb column type" do
      before do
        metadata_column = double
        allow(metadata_column).to receive_messages(sql_type: "jsonb")
        allow(MyActiveRecordModelTransition).to receive_messages(columns_hash:
                                           { "metadata" => metadata_column })
        serialized_type = ActiveRecord::Type::Serialized.new(
          "", ActiveRecord::Coders::JSON
        )
        expect(MyActiveRecordModelTransition).
          to receive(:type_for_attribute).with("metadata").
          and_return(serialized_type)
      end

      it "raises an exception" do
        expect do
          described_class.new(MyActiveRecordModelTransition,
                              MyActiveRecordModel, observer)
        end.to raise_exception(Statesman::IncompatibleSerializationError)
      end
    end
  end

  describe "#create" do
    subject(:transition) { create }

    let!(:adapter) { described_class.new(transition_class, model, observer) }
    let(:from) { :x }
    let(:to) { :y }
    let(:create) { adapter.create(from, to) }

    it "only connects to the primary database" do
      expect { create }.to exactly_query_databases({ primary: [:writing] })
    end

    context "when there is a race" do
      it "raises a TransitionConflictError" do
        adapter2 = adapter.dup
        adapter2.create(:x, :y)
        adapter.last
        adapter2.create(:y, :z)
        expect { adapter.create(:y, :z) }.
          to raise_exception(Statesman::TransitionConflictError).
          and exactly_query_databases({ primary: [:writing] })
      end

      it "does not pollute the state when the transition fails" do
        # this increments the sort_key in the database
        adapter.create(:x, :y)

        # we then pre-load the transitions for efficiency
        preloaded_model = MyActiveRecordModel.
          includes(:my_active_record_model_transitions).
          find(model.id)

        adapter2 = described_class.
          new(MyActiveRecordModelTransition, preloaded_model, observer)

        # Now we generate a race
        adapter.create(:y, :z)
        expect { adapter2.create(:y, :a) }.
          to raise_error(Statesman::TransitionConflictError)

        # The preloaded adapter should discard the preloaded info
        expect(adapter2.last).to have_attributes(to_state: "z")
        expect(adapter2.history).to contain_exactly(
          have_attributes(to_state: "y"),
          have_attributes(to_state: "z"),
        )
      end
    end

    context "when other exceptions occur" do
      before do
        allow_any_instance_of(MyActiveRecordModelTransition).
          to receive(:save!).and_raise(error)
      end

      context "ActiveRecord::RecordNotUnique unrelated to this transition" do
        let(:error) do
          if ActiveRecord.respond_to?(:gem_version) &&
              ActiveRecord.gem_version >= Gem::Version.new("4.0.0")
            ActiveRecord::RecordNotUnique.new("unrelated")
          else
            ActiveRecord::RecordNotUnique.new("unrelated", nil)
          end
        end

        it { expect { transition }.to raise_exception(ActiveRecord::RecordNotUnique) }
      end

      context "other errors" do
        let(:error) { StandardError }

        it { expect { transition }.to raise_exception(StandardError) }
      end
    end

    describe "updating the most_recent column" do
      context "with no previous transition" do
        its(:most_recent) { is_expected.to eq(true) }
      end

      context "with a previous transition" do
        let!(:previous_transition) { adapter.create(from, to) }

        its(:most_recent) { is_expected.to eq(true) }

        it "updates the previous transition's most_recent flag" do
          expect { create }.
            to change { previous_transition.reload.most_recent }.
            from(true).to be_falsey
        end

        it "touches the previous transition's updated_at timestamp" do
          expect { Timecop.freeze(Time.now + 5.seconds) { create } }.
            to(change { previous_transition.reload.updated_at })
        end

        context "for a transition class without an updated timestamp column attribute" do
          let!(:adapter) do
            described_class.new(MyActiveRecordModelTransitionWithoutInclude,
                                model,
                                observer)
          end

          it "defaults to touching the previous transition's updated_at timestamp" do
            expect { Timecop.freeze(Time.now + 5.seconds) { create } }.
              to(change { previous_transition.reload.updated_at })
          end
        end

        context "with a custom updated timestamp column set" do
          around do |example|
            MyActiveRecordModelTransition.updated_timestamp_column.tap do |original_value|
              MyActiveRecordModelTransition.updated_timestamp_column = :updated_on
              example.run
              MyActiveRecordModelTransition.updated_timestamp_column = original_value
            end
          end

          it "touches the previous transition's updated_on timestamp" do
            expect { Timecop.freeze(Time.now + 1.day) { create } }.
              to(change { previous_transition.reload.updated_on })
          end

          it "doesn't update the updated_at column" do
            expect { Timecop.freeze(Time.now + 5.seconds) { create } }.
              to_not(change { previous_transition.reload.updated_at })
          end
        end

        context "with no updated timestamp column set" do
          around do |example|
            MyActiveRecordModelTransition.updated_timestamp_column.tap do |original_value|
              MyActiveRecordModelTransition.updated_timestamp_column = nil
              example.run
              MyActiveRecordModelTransition.updated_timestamp_column = original_value
            end
          end

          it "just updates the most_recent" do
            expect { Timecop.freeze(Time.now + 5.seconds) { create } }.
              to(change { previous_transition.reload.most_recent })
          end

          it "doesn't update the updated_at column" do
            expect { Timecop.freeze(Time.now + 5.seconds) { create } }.
              to_not(change { previous_transition.reload.updated_at })
          end
        end

        context "and a query on the parent model's state is made" do
          context "in a before action" do
            it "still has the old state" do
              allow(observer).to receive(:execute) do |phase|
                next unless phase == :before

                expect(
                  model.transitions.where(most_recent: true).first.to_state,
                ).to eq("y")
              end

              adapter.create(:y, :z)
            end
          end

          context "in an after action" do
            it "still has the old state" do
              allow(observer).to receive(:execute) do |phase|
                next unless phase == :after

                expect(
                  model.transitions.where(most_recent: true).first.to_state,
                ).to eq("z")
              end

              adapter.create(:y, :z)
            end
          end
        end
      end

      context "with two previous transitions" do
        let!(:previous_transition) { adapter.create(from, to) }
        let!(:another_previous_transition) { adapter.create(from, to) }

        its(:most_recent) { is_expected.to eq(true) }

        it "updates the previous transition's most_recent flag" do
          expect { create }.
            to change { another_previous_transition.reload.most_recent }.
            from(true).to be_falsey
        end
      end

      context "when transition uses STI" do
        let(:sti_model) { StiActiveRecordModel.create }

        let(:adapter_a) do
          described_class.new(
            StiAActiveRecordModelTransition,
            sti_model,
            observer,
            { association_name: :sti_a_active_record_model_transitions },
          )
        end
        let(:adapter_b) do
          described_class.new(
            StiBActiveRecordModelTransition,
            sti_model,
            observer,
            { association_name: :sti_b_active_record_model_transitions },
          )
        end
        let(:create) { adapter_a.create(from, to) }

        context "with a previous unrelated transition" do
          let!(:transition_b) { adapter_b.create(from, to) }

          its(:most_recent) { is_expected.to eq(true) }

          it "doesn't update the previous transition's most_recent flag" do
            expect { create }.
              to_not(change { transition_b.reload.most_recent })
          end
        end

        context "with previous related and unrelated transitions" do
          let!(:transition_a) { adapter_a.create(from, to) }
          let!(:transition_b) { adapter_b.create(from, to) }

          its(:most_recent) { is_expected.to eq(true) }

          it "updates the previous transition's most_recent flag" do
            expect { create }.
              to change { transition_a.reload.most_recent }.
              from(true).to be_falsey
          end

          it "doesn't update the previous unrelated transition's most_recent flag" do
            expect { create }.
              to_not(change { transition_b.reload.most_recent })
          end
        end
      end
    end

    context "when using the secondary database" do
      let(:model_class) { SecondaryActiveRecordModel }
      let(:transition_class) { SecondaryActiveRecordModelTransition }

      it "doesn't connect to the primary database" do
        expect { create }.to exactly_query_databases({ secondary: [:writing] })
        expect(adapter.last.to_state).to eq("y")
      end

      context "when there is a race" do
        it "raises a TransitionConflictError and uses the correct database" do
          adapter2 = adapter.dup
          adapter2.create(:x, :y)
          adapter.last
          adapter2.create(:y, :z)

          expect { adapter.create(:y, :z) }.
            to raise_exception(Statesman::TransitionConflictError).
            and exactly_query_databases({ secondary: [:writing] })
        end
      end
    end
  end

  describe "#last" do
    let(:transition_class) { MyActiveRecordModelTransition }
    let(:adapter) { described_class.new(transition_class, model, observer) }

    context "with a previously looked up transition" do
      before do
        adapter.create(:x, :y)
        adapter.last
      end

      it "caches the transition" do
        expect_any_instance_of(MyActiveRecordModel).
          to_not receive(:my_active_record_model_transitions)
        adapter.last
      end

      context "after then creating a new transition" do
        before { adapter.create(:y, :z, []) }

        it "retrieves the new transition from the database" do
          expect { adapter.last.to_state }.to exactly_query_databases({ primary: [:writing] })
          expect(adapter.last.to_state).to eq("z")
        end

        context "when using the secondary database" do
          let(:model_class) { SecondaryActiveRecordModel }
          let(:transition_class) { SecondaryActiveRecordModelTransition }

          it "retrieves the new transition from the database" do
            expect { adapter.last.to_state }.to exactly_query_databases({ secondary: [:writing] })
            expect(adapter.last.to_state).to eq("z")
          end
        end
      end

      context "when a new transition has been created elsewhere" do
        let(:alternate_adapter) do
          described_class.new(MyActiveRecordModelTransition, model, observer)
        end

        it "still returns the cached value" do
          alternate_adapter.create(:y, :z, [])

          expect_any_instance_of(MyActiveRecordModel).
            to_not receive(:my_active_record_model_transitions)
          expect(adapter.last.to_state).to eq("y")
        end

        context "when explicitly not using the cache" do
          context "when the transitions are in memory" do
            before do
              model.my_active_record_model_transitions.load
              alternate_adapter.create(:y, :z, [])
            end

            it "reloads the value" do
              expect(adapter.last(force_reload: true).to_state).to eq("z")
            end
          end

          context "when the transitions are not in memory" do
            before do
              model.my_active_record_model_transitions.reset
              alternate_adapter.create(:y, :z, [])
            end

            it "reloads the value" do
              expect(adapter.last(force_reload: true).to_state).to eq("z")
            end
          end
        end
      end
    end

    context "with a pre-fetched transition history" do
      before do
        adapter.create(:x, :y)
        model.my_active_record_model_transitions.load_target
      end

      it "doesn't query the database" do
        expect(MyActiveRecordModelTransition).to_not receive(:connection)
        expect(adapter.last.to_state).to eq("y")
      end
    end

    context "without previous transitions" do
      it "does query the database only once" do
        expect(model.my_active_record_model_transitions).
          to receive(:order).once.and_call_original

        expect(adapter.last).to eq(nil)
        expect(adapter.last).to eq(nil)
      end
    end
  end

  describe "#reset" do
    it "works with empty cache" do
      expect { model.state_machine.reset }.to_not raise_error
    end
  end

  it "resets last with #reload" do
    model.save!
    ActiveRecord::Base.transaction do
      model.state_machine.transition_to!(:succeeded)
      # force to cache value in last_transition instance variable
      expect(model.state_machine.current_state).to eq("succeeded")
      raise ActiveRecord::Rollback
    end
    expect(model.state_machine.current_state).to eq("succeeded")
    model.reload
    expect(model.state_machine.current_state).to eq("initial")
  end

  context "with a namespaced model" do
    before do
      CreateNamespacedARModelMigration.migrate(:up)
      CreateNamespacedARModelTransitionMigration.migrate(:up)
    end

    let(:observer) { double(Statesman::Machine, execute: nil) }
    let(:model) do
      MyNamespace::MyActiveRecordModel.create(current_state: :pending)
    end

    it_behaves_like "an adapter",
                    described_class, MyNamespace::MyActiveRecordModelTransition,
                    association_name: :my_active_record_model_transitions
  end
end
