# frozen_string_literal: true

RSpec.describe "Transactions" do
  include_context "database"

  include Dry::Monads[:result]

  let(:dependencies) { {} }

  before do
    container.instance_exec do
      register :process,  -> input { {name: input["name"], email: input["email"]} }
      register :verify,   -> input { Success(input) }
      register :validate, -> input { input[:email].nil? ? raise(Test::NotValidError, "email required") : input }
      register :persist,  -> input { self[:database] << input and true }
    end
  end

  context "successful" do
    let(:transaction) {
      Class.new do
        include Dry::Transaction(container: Test::Container)
        map :process, with: :process
        step :verify, with: :verify
        try :validate, with: :validate, catch: Test::NotValidError
        tee :persist, with: :persist
      end.new(**dependencies)
    }
    let(:input) { {"name" => "Jane", "email" => "jane@doe.com"} }

    it "calls the operations" do
      transaction.call(input)
      expect(database).to include(name: "Jane", email: "jane@doe.com")
    end

    it "returns a success" do
      expect(transaction.call(input)).to be_a Dry::Monads::Result::Success
    end

    it "wraps the result of the final operation" do
      expect(transaction.call(input).value!).to eq(name: "Jane", email: "jane@doe.com")
    end

    it "can be called multiple times to the same effect" do
      transaction.call(input)
      transaction.call(input)

      expect(database[0]).to eql(name: "Jane", email: "jane@doe.com")
      expect(database[1]).to eql(name: "Jane", email: "jane@doe.com")
    end

    it "supports matching on success" do
      results = []

      transaction.call(input) do |m|
        m.success do |value|
          results << "success for #{value[:email]}"
        end

        m.failure {}
      end

      expect(results.first).to eq "success for jane@doe.com"
    end
  end

  context "different step names" do
    before do
      class Test::ContainerNames
        extend Dry::Core::Container::Mixin
        register :process_step,  -> input { {name: input["name"], email: input["email"]} }
        register :verify_step,   -> input { Dry::Monads::Success(input) }
        register :persist_step,  -> input { Test::DB << input and true }
      end
    end

    let(:transaction) {
      Class.new do
        include Dry::Transaction(container: Test::ContainerNames)

        map :process, with: :process_step
        step :verify, with: :verify_step
        tee :persist, with: :persist_step
      end.new(**dependencies)
    }

    it "supports steps using differently named container operations" do
      transaction.call("name" => "Jane", "email" => "jane@doe.com")
      expect(database).to include(name: "Jane", email: "jane@doe.com")
    end
  end

  describe "operation injection" do
    let(:transaction) {
      Class.new do
        include Dry::Transaction(container: Test::Container)
        map :process, with: :process
        step :verify_step, with: :verify
        tee :persist, with: :persist
      end.new(**dependencies)
    }

    let(:dependencies) {
      {verify_step: -> input { Success(input.merge(foo: :bar)) }}
    }

    it "calls injected operations" do
      transaction.call("name" => "Jane", "email" => "jane@doe.com")

      expect(database).to include(name: "Jane", email: "jane@doe.com", foo: :bar)
    end
  end

  context "wrapping operations with local methods" do
    let(:transaction) do
      Class.new do
        include Dry::Transaction(container: Test::Container)

        map :process, with: :process
        step :verify, with: :verify
        tee :persist, with: :persist

        def verify(input)
          new_input = input.merge(greeting: "hello!")
          super(new_input)
        end
      end.new(**dependencies)
    end

    let(:dependencies) { {} }

    it "allows local methods to run operations via super" do
      transaction.call("name" => "Jane", "email" => "jane@doe.com")

      expect(database).to include(name: "Jane", email: "jane@doe.com", greeting: "hello!")
    end
  end

  context "wrapping operations with private local methods" do
    let(:transaction) do
      Class.new do
        include Dry::Transaction(container: Test::Container)

        map :process, with: :process
        step :verify, with: :verify
        tee :persist, with: :persist

        private

        def verify(input)
          new_input = input.merge(greeting: "hello!")
          super(new_input)
        end
      end.new(**dependencies)
    end

    it "allows local methods to run operations via super" do
      transaction.call("name" => "Jane", "email" => "jane@doe.com")

      expect(database).to include(name: "Jane", email: "jane@doe.com", greeting: "hello!")
    end
  end

  context "operation injection of step only defined in the transaction class not in the container" do
    let(:transaction) do
      Class.new do
        include Dry::Transaction

        step :process

        def process(input)
          new_input = input << :world
          super(new_input)
        end
      end.new(**dependencies)
    end

    let(:dependencies) do
      {process: -> input { Failure(input) }}
    end

    # FIXME: needs a better description
    it "execute the transaction and execute the injected operation" do
      result = transaction.call([:hello])

      expect(result).to eq Failure([:hello])
    end
  end

  context "operation injection of step without container and no transaction instance methods" do
    let(:transaction) do
      Class.new do
        include Dry::Transaction

        map :process
        step :verify
        try :validate, catch: Test::NotValidError
        tee :persist
      end.new(**dependencies)
    end

    let(:dependencies) do
      {
        process: -> input { {name: input["name"], email: input["email"]} },
        verify: -> input { Success(input) },
        validate: -> input { input[:email].nil? ? raise(Test::NotValidError, "email required") : input },
        persist: -> input { database << input and true }
      }
    end

    let(:input) { {"name" => "Jane", "email" => "jane@doe.com"} }

    it "calls the injected operations" do
      transaction.call(input)
      expect(database).to include(name: "Jane", email: "jane@doe.com")
    end
  end

  context "operation injection of step without container and no transaction instance methods but missing an injected operation" do
    let(:transaction) do
      Class.new do
        include Dry::Transaction

        map :process
        step :verify
        try :validate, catch: Test::NotValidError
        tee :persist
      end.new(**dependencies)
    end

    let(:dependencies) do
      {
        process: -> input { {name: input["name"], email: input["email"]} },
        verify: -> input { Success(input) },
        validate: -> input { input[:email].nil? ? raise(Test::NotValidError, "email required") : input }
      }
    end

    let(:input) { {"name" => "Jane", "email" => "jane@doe.com"} }

    it "raises an exception" do
      expect { transaction }.to raise_error(Dry::Transaction::MissingStepError)
    end
  end

  context "local step definition" do
    let(:transaction) do
      Class.new do
        include Dry::Transaction(container: Test::Container)

        map :process, with: :process
        step :verify
        tee :persist, with: :persist

        def verify(input)
          Success(input.keys)
        end
      end.new
    end

    it "execute step only defined as local method" do
      transaction.call("name" => "Jane", "email" => "jane@doe.com")

      expect(database).to include([:name, :email])
    end
  end

  context "local step definition not in container" do
    let(:transaction) do
      Class.new do
        include Dry::Transaction(container: Test::Container)

        map :process, with: :process
        step :verify_only_local
        tee :persist, with: :persist

        def verify_only_local(input)
          Success(input.keys)
        end
      end.new
    end

    it "execute step only defined as local method" do
      transaction.call("name" => "Jane", "email" => "jane@doe.com")

      expect(database).to include([:name, :email])
    end
  end

  context "all steps are local methods" do
    let(:transaction_class) do
      Class.new do
        include Dry::Transaction

        map :process
        step :verify
        tee :persist

        def process(input)
          input.to_a
        end

        def verify(input)
          Success(input)
        end

        def persist(input)
          Test::Container[:database] << input and true
        end
      end
    end

    it "executes succesfully" do
      transaction_class.new.call("name" => "Jane", "email" => "jane@doe.com")
      expect(database).to include([%w[name Jane], %w[email jane@doe.com]])
    end

    it "allows replacement steps to be injected" do
      verify = -> { Failure("nope") }

      result = transaction_class.new(verify: verify).("name" => "Jane", "email" => "jane@doe.com")
      expect(result).to eq Failure("nope")
    end
  end

  context "failed in a try step" do
    let(:transaction) {
      Class.new do
        include Dry::Transaction(container: Test::Container)
        map :process, with: :process
        step :verify, with: :verify
        try :validate, with: :validate, catch: Test::NotValidError
        tee :persist, with: :persist
      end.new(**dependencies)
    }
    let(:input) { {"name" => "Jane"} }

    it "does not run subsequent operations" do
      transaction.call(input)
      expect(database).to be_empty
    end

    it "returns a failure" do
      expect(transaction.call(input)).to be_a Dry::Monads::Result::Failure
    end

    it "wraps the result of the failing operation" do
      expect(transaction.call(input).failure).to be_a Test::NotValidError
    end

    it "supports matching on failure" do
      results = []

      transaction.call(input) do |m|
        m.success {}

        m.failure do |value|
          results << "Failed: #{value}"
        end
      end

      expect(results.first).to eq "Failed: email required"
    end

    it "supports matching on specific step failures" do
      results = []

      transaction.call(input) do |m|
        m.success {}

        m.failure :validate do |value|
          results << "Validation failure: #{value}"
        end
      end

      expect(results.first).to eq "Validation failure: email required"
    end

    it "supports matching on un-named step failures" do
      results = []

      transaction.call(input) do |m|
        m.success {}

        m.failure :some_other_step do |_value|
          results << "Some other step failure"
        end

        m.failure do |value|
          results << "Catch-all failure: #{value}"
        end
      end

      expect(results.first).to eq "Catch-all failure: email required"
    end
  end

  context "failed in a raw step" do
    let(:input) { {"name" => "Jane", "email" => "jane@doe.com"} }

    before do
      class Test::ContainerRaw
        extend Dry::Core::Container::Mixin
        extend Dry::Monads[:result]
        register :process_step,  -> input { {name: input["name"], email: input["email"]} }
        register :verify_step,   -> _input { Failure("raw failure") }
        register :persist_step,  -> input { self[:database] << input and true }
      end
    end

    let(:transaction) {
      Class.new do
        include Dry::Transaction(container: Test::ContainerRaw)

        map :process, with: :process_step
        step :verify, with: :verify_step
        tee :persist, with: :persist_step
      end.new(**dependencies)
    }

    it "does not run subsequent operations" do
      transaction.call(input)
      expect(database).to be_empty
    end

    it "returns a failure" do
      expect(transaction.call(input)).to be_a_failure
    end

    it "returns the failing value from the operation" do
      expect(transaction.call(input).failure).to eq "raw failure"
    end

    it "returns an object that quacks like expected" do
      result = transaction.call(input).failure

      expect(Array(result)).to eq(["raw failure"])
    end

    it "does not allow to call private methods on the result accidently" do
      result = transaction.call(input).failure

      expect { result.print("") }.to raise_error(NoMethodError)
    end
  end

  context "non-confirming raw step result" do
    let(:input) { {"name" => "Jane", "email" => "jane@doe.com"} }

    let(:transaction) {
      Class.new do
        include Dry::Transaction(container: Test::ContainerRaw)
        map :process
        step :verify
        tee :persist
      end.new(**dependencies)
    }

    before do
      class Test::ContainerRaw
        extend Dry::Core::Container::Mixin
        register :process,  -> input { {name: input["name"], email: input["email"]} }
        register :verify,   -> _input { "failure" }
        register :persist,  -> input { Test::DB << input and true }
      end
    end

    it "raises an exception" do
      expect { transaction.call(input) }.to raise_error(ArgumentError)
    end
  end

  context "keyword arguments" do
    let(:input) { {name: "jane", age: 20} }

    let(:upcaser) do
      Class.new {
        include Dry::Monads[:result]

        def call(name: "John", **rest)
          Success({name: name[0].upcase + name[1..], **rest})
        end
      }.new
    end

    let(:transaction) do
      Class.new {
        include Dry::Transaction

        step :camelize
      }.new(camelize: upcaser)
    end

    it "calls the operations" do
      expect(transaction.(input).value!).to eql(name: "Jane", age: 20)
    end
  end

  context "hash-like arguments" do
    let(:sym_key_hash_class) {
      Class.new(Hash) {
        def initialize(hash)
          hash.keys.each do |key|
            hash[key.to_sym] = hash.delete(key)
          end
          super(hash)
        end

        def [](key)
          super[key.to_sym]
        end
      }
    }

    let(:transaction) do
      Class.new do
        include Dry::Transaction

        try :validate, with: :validate, catch: Test::NotValidError
      end.new(**dependencies)
    end

    let(:dependencies) do
      {
        validate: -> input { input[:email].nil? ? raise(Test::NotValidError, "email required") : input }
      }
    end

    let(:input) { sym_key_hash_class.new({"name" => "Jane", "email" => "jane@doe.com"}) }

    it "returns a success" do
      expect(transaction.call(input)).to be_a Dry::Monads::Result::Success
    end

    it "returns a failure without deprecation warning" do
      expect(transaction.call({})).to be_a Dry::Monads::Result::Failure
    end

    it "wraps the result of the final operation" do
      expect(transaction.call(input).value![:name]).to eq("Jane")
    end

    it "returns the original class type" do
      expect(transaction.call(input).value!.class).to eq(sym_key_hash_class)
    end
  end

  context "invalid steps" do
    context "non-callable step" do
      context "with container" do
        let(:input) { {} }

        let(:transaction) {
          Class.new do
            include Dry::Transaction(container: Test::ContainerRaw)
            map :not_a_proc, with: :not_a_proc
          end.new
        }

        before do
          class Test::ContainerRaw
            extend Dry::Core::Container::Mixin

            register :not_a_proc, "definitely not a proc"
          end
        end

        it "raises an exception" do
          expect { transaction.call(input) }.to raise_error(Dry::Transaction::InvalidStepError)
        end
      end
    end

    context "missing steps" do
      context "no container" do
        let(:input) { {} }

        let(:transaction) {
          Class.new do
            include Dry::Transaction
            map :noop
            map :i_am_missing

            def noop
              Success(input)
            end
          end.new
        }

        it "raises an exception" do
          expect { transaction.call(input) }.to raise_error(Dry::Transaction::MissingStepError)
        end
      end

      context "with container" do
        let(:input) { {} }

        let(:transaction) {
          Class.new do
            include Dry::Transaction(container: Test::ContainerRaw)
            map :noop
            map :i_am_missing
          end.new
        }

        before do
          class Test::ContainerRaw
            extend Dry::Core::Container::Mixin

            register :noop, -> input { Success(input) }
          end
        end

        it "raises an exception" do
          expect { transaction.call(input) }.to raise_error(Dry::Transaction::MissingStepError)
        end
      end
    end
  end
end
