# frozen_string_literal: true

require "forwardable"
require_relative "the_bundle"

module Spec
  module Matchers
    extend RSpec::Matchers

    class Precondition
      include RSpec::Matchers::Composable
      extend Forwardable
      def_delegators :failing_matcher,
        :failure_message,
        :actual,
        :description,
        :diffable?,
        :expected,
        :failure_message_when_negated

      def initialize(matcher, preconditions)
        @matcher = with_matchers_cloned(matcher)
        @preconditions = with_matchers_cloned(preconditions)
        @failure_index = nil
      end

      def matches?(target, &blk)
        return false if @failure_index = @preconditions.index {|pc| !pc.matches?(target, &blk) }
        @matcher.matches?(target, &blk)
      end

      def does_not_match?(target, &blk)
        return false if @failure_index = @preconditions.index {|pc| !pc.matches?(target, &blk) }
        if @matcher.respond_to?(:does_not_match?)
          @matcher.does_not_match?(target, &blk)
        else
          !@matcher.matches?(target, &blk)
        end
      end

      def expects_call_stack_jump?
        @matcher.expects_call_stack_jump? || @preconditions.any?(&:expects_call_stack_jump)
      end

      def supports_block_expectations?
        @matcher.supports_block_expectations? || @preconditions.any?(&:supports_block_expectations)
      end

      def failing_matcher
        @failure_index ? @preconditions[@failure_index] : @matcher
      end
    end

    def self.define_compound_matcher(matcher, preconditions, &declarations)
      raise "Must have preconditions to define a compound matcher" if preconditions.empty?
      define_method(matcher) do |*expected, &block_arg|
        Precondition.new(
          RSpec::Matchers::DSL::Matcher.new(matcher, declarations, self, *expected, &block_arg),
          preconditions
        )
      end
    end

    RSpec::Matchers.define :have_dep do |*args|
      dep = Bundler::Dependency.new(*args)

      match do |actual|
        actual.length == 1 && actual.all? {|d| d == dep }
      end
    end

    RSpec::Matchers.define :have_gem do |*args|
      match do |actual|
        actual.length == args.length && actual.all? {|a| args.include?(a.full_name) }
      end
    end

    RSpec::Matchers.define :be_sorted do
      diffable
      attr_reader :expected
      match do |actual|
        expected = block_arg ? actual.sort_by(&block_arg) : actual.sort
        actual.==(expected).tap do
          # HACK: since rspec won't show a diff when everything is a string
          differ = RSpec::Support::Differ.new
          @actual = differ.send(:object_to_string, actual)
          @expected = differ.send(:object_to_string, expected)
        end
      end
    end

    RSpec::Matchers.define :be_well_formed do
      match(&:empty?)

      failure_message do |actual|
        actual.join("\n")
      end
    end

    RSpec::Matchers.define :take_less_than do |seconds|
      match do |actual|
        start_time = Time.now

        actual.call

        (Time.now - start_time).to_f < seconds
      end

      supports_block_expectations
    end

    define_compound_matcher :read_as, [exist] do |file_contents|
      diffable

      match do |actual|
        @actual = Bundler.read_file(actual)
        values_match?(file_contents, @actual)
      end
    end

    def indent(string, padding = 4, indent_character = " ")
      string.to_s.gsub(/^/, indent_character * padding).gsub("\t", "    ")
    end

    define_compound_matcher :include_gems, [be_an_instance_of(Spec::TheBundle)] do |*names|
      match do
        opts = names.last.is_a?(Hash) ? names.pop : {}
        source = opts.delete(:source)
        groups = Array(opts.delete(:groups)).map(&:inspect).join(", ")
        opts[:raise_on_error] = false
        @errors = names.map do |full_name|
          name, version, platform = full_name.split(/\s+/)
          require_path = name.tr("-", "/")
          version_const = name == "bundler" ? "Bundler::VERSION" : Spec::Builders.constantize(name)
          source_const = "#{Spec::Builders.constantize(name)}_SOURCE"
          ruby <<~R, opts
            require 'bundler'
            Bundler.setup(#{groups})

            require '#{require_path}'
            actual_version, actual_platform = #{version_const}.split(/\s+/, 2)
            unless Gem::Version.new(actual_version) == Gem::Version.new('#{version}')
              puts actual_version
              exit 64
            end
            unless actual_platform.to_s == '#{platform}'
              puts actual_platform
              exit 65
            end
            require '#{require_path}/source'
            exit 0 if #{source.nil?}
            actual_source = #{source_const}
            unless actual_source == '#{source}'
              puts actual_source
              exit 66
            end
          R
          next if exitstatus == 0
          if exitstatus == 64
            actual_version = out.split("\n").last
            next "#{name} was expected to be at version #{version} but was #{actual_version}"
          end
          if exitstatus == 65
            actual_platform = out.split("\n").last
            next "#{name} was expected to be of platform #{platform || "ruby"} but was #{actual_platform || "ruby"}"
          end
          if exitstatus == 66
            actual_source = out.split("\n").last
            next "Expected #{name} (#{version}) to be installed from `#{source}`, was actually from `#{actual_source}`"
          end
          next "Command to check for inclusion of gem #{full_name} failed"
        end.compact

        @errors.empty?
      end

      match_when_negated do
        opts = names.last.is_a?(Hash) ? names.pop : {}
        groups = Array(opts.delete(:groups)).map(&:inspect).join(", ")
        opts[:raise_on_error] = false
        @errors = names.map do |name|
          name, version = name.split(/\s+/, 2)
          ruby <<-R, opts
            begin
              require 'bundler'
              Bundler.setup(#{groups})
            rescue Bundler::GemNotFound, Bundler::GitError
              exit 0
            end

            begin
              require '#{name}'
              name_constant = #{Spec::Builders.constantize(name)}
              if #{version.nil?} || name_constant == '#{version}'
                exit 64
              else
                exit 0
              end
            rescue LoadError, NameError
              exit 0
            end
          R
          next if exitstatus == 0
          next "command to check version of #{name} installed failed" unless exitstatus == 64
          next "expected #{name} to not be installed, but it was" if version.nil?
          next "expected #{name} (#{version}) not to be installed, but it was"
        end.compact

        @errors.empty?
      end

      failure_message do
        super() + " but:\n" + @errors.map {|e| indent(e) }.join("\n")
      end

      failure_message_when_negated do
        super() + " but:\n" + @errors.map {|e| indent(e) }.join("\n")
      end
    end
    RSpec::Matchers.define_negated_matcher :not_include_gems, :include_gems
    RSpec::Matchers.alias_matcher :include_gem, :include_gems

    def plugin_should_be_installed(*names)
      names.each do |name|
        expect(Bundler::Plugin).to be_installed(name)
        path = Pathname.new(Bundler::Plugin.installed?(name))
        expect(path + "plugins.rb").to exist
      end
    end

    def plugin_should_not_be_installed(*names)
      names.each do |name|
        expect(Bundler::Plugin).not_to be_installed(name)
      end
    end
  end
end
