module RSpec
  module Rails
    module Swagger
      module Helpers
        # paths: (Paths)
        #   /pets: (Path Item)
        #     post: (Operation)
        #       tags:
        #         - pet
        #       summary: Add a new pet to the store
        #       description: ""
        #       operationId: addPet
        #       consumes:
        #         - application/json
        #       produces:
        #         - application/json
        #       parameters: (Parameters)
        #         - in: body
        #           name: body
        #           description: Pet object that needs to be added to the store
        #           required: false
        #           schema:
        #             $ref: "#/definitions/Pet"
        #       responses: (Responses)
        #         "405": (Response)
        #           description: Invalid input

        # The helpers serve as a DSL.
        def self.add_swagger_type_configurations(config)
          # The filters are used to ensure that the methods are nested correctly
          # and following the Swagger schema.
          config.extend Paths,       type: :request
          config.extend PathItem,    swagger_object: :path_item
          config.extend Parameters,  swagger_object: :path_item
          config.extend Operation,   swagger_object: :operation
          config.extend Parameters,  swagger_object: :operation
          config.extend Response,    swagger_object: :response
        end

        module Paths
          def path template, attributes = {}, &block
            attributes.symbolize_keys!

            raise ArgumentError, "Path must start with a /" unless template.starts_with?('/')
            #TODO template might be a $ref
            meta = {
              swagger_object: :path_item,
              swagger_doc: attributes[:swagger_doc] || default_document,
              swagger_path_item: {path: template},
            }
            # Merge tags passed into the path with those from parent contexts.
            if attributes[:tags]
              meta[:tags] = (metadata.try(:[], :tags) || []) + attributes[:tags]
            end
            describe(template, meta, &block)
          end

          private

          def default_document
            metadata.try(:[], :swagger_doc) || RSpec.configuration.swagger_docs.keys.first
          end
        end

        module PathItem
          METHODS = %w(get put post delete options head patch).freeze

          def operation method, attributes = {}, &block
            attributes.symbolize_keys!
            # Include tags from parent contexts so you can tag all the paths
            # in a controller at once.
            if metadata.try(:[], :tags).present?
              attributes[:tags] ||= []
              attributes[:tags] += metadata[:tags]
            end

            method = method.to_s.downcase
            validate_method! method

            meta = {
              swagger_object: :operation,
              swagger_operation: attributes.merge(method: method.to_sym).reject{ |v| v.nil? }
            }
            describe(method.to_s, meta, &block)
          end

          METHODS.each do |method|
            define_method(method) do |attributes = {}, &block|
              operation(method, attributes, &block)
            end
          end

          private

          def validate_method! method
            unless METHODS.include? method.to_s
              raise ArgumentError, "Operation has an invalid 'method' value. Try: #{METHODS}."
            end
          end
        end

        module Parameters
          def parameter name, attributes = {}
            attributes.symbolize_keys!

            # Look for $refs
            if name.respond_to?(:has_key?)
              ref = name.delete(:ref) || name.delete('ref')
              full_param = resolve_document(metadata).resolve_ref(ref)

              validate_parameter! full_param

              param = { '$ref' => ref }
              key = parameter_key(full_param)
            else
              validate_parameter! attributes

              # Path attributes are always required
              attributes[:required] = true if attributes[:in] == :path

              param = { name: name.to_s }.merge(attributes)
              key = parameter_key(param)
            end

            parameters_for_object[key] = param
          end

          def resolve_document metadata
            # TODO: It's really inefficient to keep recreating this. It'd be nice
            # if we could cache them some place.
            name = metadata[:swagger_doc]
            Document.new(RSpec.configuration.swagger_docs[name])
          end

          private

          # This key ensures uniqueness based on the 'name' and 'in' values.
          def parameter_key parameter
            "#{parameter[:in]}&#{parameter[:name]}"
          end

          def parameters_for_object
            object_key = "swagger_#{metadata[:swagger_object]}".to_sym
            object_data = metadata[object_key] ||= {}
            object_data[:parameters] ||= {}
          end

          def validate_parameter! attributes
            validate_location! attributes[:in]

            if attributes[:in].to_s == 'body'
              unless attributes[:schema].present?
                raise ArgumentError, "Parameter is missing required 'schema' value."
              end
            else
              validate_type! attributes[:type]
            end
          end

          def validate_location! location
            unless location.present?
              raise ArgumentError, "Parameter is missing required 'in' value."
            end

            locations = %w(query header path formData body)
            unless locations.include? location.to_s
              raise ArgumentError, "Parameter has an invalid 'in' value. Try: #{locations}."
            end
          end

          def validate_type! type
            unless type.present?
              raise ArgumentError, "Parameter is missing required 'type' value."
            end

            types = %w(string number integer boolean array file)
            unless types.include? type.to_s
              raise ArgumentError, "Parameter has an invalid 'type' value. Try: #{types}."
            end
          end
        end

        module Operation
          def consumes *mime_types
            metadata[:swagger_operation][:consumes] = mime_types
          end

          def produces *mime_types
            metadata[:swagger_operation][:produces] = mime_types
          end

          def tags *tags
            metadata[:swagger_operation][:tags] ||= []
            metadata[:swagger_operation][:tags] += tags
          end

          def response status_code, attributes = {}, &block
            attributes.symbolize_keys!

            validate_status_code! status_code
            validate_description! attributes[:description]

            meta = {
              swagger_object: :response,
              swagger_response: attributes.merge(status_code: status_code)
            }
            describe(status_code, meta) do
              self.module_exec(&block) if block_given?

              # To make a request we need:
              # - the details we've collected in the metadata
              # - parameter values defined using let()
              # RSpec tries to limit access to metadata inside of it() / before()
              # / after() blocks but that scope is the only place you can access
              # the let() values. The solution the swagger_rails dev came up with
              # is to use the example.metadata passed into the block with the
              # block's scope which has access to the let() values.
              before do |example|
                builder = RequestBuilder.new(example.metadata, self)
                method = builder.method
                path = [builder.path, builder.query].join
                headers = builder.headers
                env = builder.env
                body = builder.body

                # Run the request
                if ::Rails::VERSION::MAJOR >= 5
                  self.send(method, path, params: body, headers: headers, env: env)
                else
                  self.send(method, path, body, headers.merge(env))
                end

                if example.metadata[:capture_examples]
                  examples = example.metadata[:swagger_response][:examples] ||= {}
                  examples[response.content_type.to_s] = response.body
                end
              end

              # TODO: see if we can get the caller to show up in the error
              # backtrace for this test.
              it("returns the correct status code") do
                expect(response).to have_http_status(status_code)
              end
            end
          end

          private

          def validate_status_code! status_code
            unless status_code == :default || (100..599).cover?(status_code)
              raise ArgumentError, "status_code must be an integer 100 to 599, or :default"
            end
          end

          def validate_description! description
            unless description.present?
              raise ArgumentError, "Response is missing required 'description' value."
            end
          end
        end

        module Response
          def capture_example
            metadata[:capture_examples] = true
          end

          def schema definition
            definition.symbolize_keys!

            ref = definition.delete(:ref)
            schema = ref ? { '$ref' => ref } : definition

            metadata[:swagger_response][:schema] = schema
          end
        end
      end
    end
  end
end
