module Neo4j
  module Server
    class CypherResponse
      attr_reader :data, :columns, :error_msg, :error_status, :error_code, :response

      class ResponseError < StandardError
        attr_reader :status, :code

        def initialize(msg, status, code)
          super(msg)
          @status = status
          @code = code
        end
      end

      class ConstraintViolationError < ResponseError; end

      class HashEnumeration
        include Enumerable
        extend Forwardable
        def_delegator :@response, :error_msg
        def_delegator :@response, :error_status
        def_delegator :@response, :error_code
        def_delegator :@response, :columns
        def_delegator :@response, :struct

        def initialize(response, query)
          @response = response
          @query = query
        end

        def to_s
          @query
        end

        def inspect
          "Enumerable query: '#{@query}'"
        end

        def each
          @response.each_data_row { |row| yield struct_rows(row) }
        end

        def struct_rows(row)
          struct.new.tap do |result|
            row.each_with_index { |value, i| result[columns[i]] = value }
          end
        end
      end

      EMPTY_STRING = ''
      def to_struct_enumeration(cypher = EMPTY_STRING)
        HashEnumeration.new(self, cypher)
      end

      def to_node_enumeration(cypher = EMPTY_STRING, session = Neo4j::Session.current)
        Enumerator.new do |yielder|
          @result_index = 0
          to_struct_enumeration(cypher).each do |row|
            @row_index = 0
            yielder << row_pair_in_struct(row, session)
            @result_index += 1
          end
        end
      end

      def row_pair_in_struct(row, session)
        @struct.new.tap do |result|
          row.each_pair do |column, value|
            result[column] = map_row_value(value, session)
            @row_index += 1
          end
        end
      end

      def map_row_value(value, session)
        if value.is_a?(Hash) && looks_like_an_object?(value)
          hash_value_as_object(value, session)
        elsif value.is_a?(Array)
          value.map! { |v| map_row_value(v, session) }
        else
          value
        end
      end

      def hash_value_as_object(value, session)
        if transaction_response?
          add_transaction_entity_id
          data = mapped_rest_data
        elsif [:node, :relationship].include?(identify_entity(value))
          add_entity_id(value)
          data = value
        else
          return value
        end

        basic_obj = (node?(value) ? CypherNode : CypherRelationship).new(session, data)
        unwrapped? ? basic_obj : basic_obj.wrapper
      end

      def identify_entity(data)
        self_string = data[:self]
        if self_string
          if self_string.include?('node')
            :node
          elsif self_string.include?('relationship')
            :relationship
          end
        elsif [:nodes, :relationships, :start, :end, :length].all? { |k| data.key?(k) }
          :path
        end
      end

      def looks_like_an_object?(value)
        if transaction_response?
          mapped_rest_data[:outgoing_relationships] || (mapped_rest_data[:start] && mapped_rest_data[:properties])
        else
          value[:labels] || value[:type]
        end
      end

      def unwrapped!
        @_unwrapped_obj = true
      end

      def unwrapped?
        !!@_unwrapped_obj
      end

      def node?(value)
        transaction_response? ? !mapped_rest_data[:start] : value[:labels]
      end

      attr_reader :struct

      def initialize(response, uncommited = false)
        @response = response
        @uncommited = uncommited
      end

      def entity_data(id = nil)
        if @uncommited
          data = @data.first[:row].first
          data.is_a?(Hash) ? {data: data, id: id} : data
        else
          data = @data[0][0]
          data.is_a?(Hash) ? add_entity_id(data) : data
        end
      end

      def first_data
        if @uncommited
          @data.first[:row].first
          # data.is_a?(Hash) ? {'data' => data, 'id' => id} : data
        else
          data = @data[0][0]
          data.is_a?(Hash) ? add_entity_id(data) : data
        end
      end

      def add_entity_id(data)
        data[:id] = if data[:metadata] && data[:metadata][:id]
                      data[:metadata][:id]
                    else
                      data[:self].split('/')[-1].to_i
                    end
        data
      end

      def add_transaction_entity_id
        mapped_rest_data[:id] = mapped_rest_data[:self].split('/').last.to_i
        mapped_rest_data
      end

      def error?
        !!@error
      end

      RETRYABLE_ERROR_STATUSES = %w(DeadlockDetectedException AcquireLockTimeoutException ExternalResourceFailureException UnknownFailureException)
      def retryable_error?
        return unless error?
        RETRYABLE_ERROR_STATUSES.include?(@error_status)
      end

      def data?
        !response.body[:data].nil?
      end

      def raise_unless_response_code(code)
        fail "Response code #{response.status}, expected #{code} for #{response.headers[:location]}, #{response.body}" unless response.status == code
      end

      def each_data_row
        data.each { |r| yield (@uncommited ? r[:row] : r) }
      end

      def set_data(response)
        @data = response[:data]
        @columns = response[:columns]
        @struct = @columns.empty? ? Object.new : Struct.new(*@columns.map(&:to_sym))
        self
      end

      def set_error(error)
        @error = true
        @error_msg = error[:message]
        @error_status = error[:status] || error[:exception] || error[:code]
        @error_code = error[:code] || error[:fullname]
        self
      end

      CONSTRAINT_ERROR = 'Neo.ClientError.Schema.ConstraintViolation'
      def raise_error
        fail 'Tried to raise error without an error' unless @error
        error_class = constraint_error? ? ConstraintViolationError : ResponseError
        fail error_class.new(@error_msg, @error_status, @error_code)
      end

      def constraint_error?
        @error_code == CONSTRAINT_ERROR || @error_msg.include?('already exists with')
      end

      def raise_cypher_error
        fail 'Tried to raise error without an error' unless @error
        fail Neo4j::Session::CypherError.new(@error_msg, @error_code, @error_status)
      end


      def self.create_with_no_tx(response)
        case response.status
        when 200 then new(response).set_data(response.body)
        when 400 then new(response).set_error(response.body)
        else
          fail "Unknown response code #{response.status} for #{response.env[:url]}"
        end
      end

      def self.create_with_tx(response)
        fail "Unknown response code #{response.status} for #{response.request_uri}" unless response.status == 200

        new(response, true).tap do |cr|
          body = response.body
          body[:errors].empty? ? cr.set_data(body[:results].first) : cr.set_error(body[:errors].first)
        end
      end

      def transaction_response?
        response.respond_to?(:body) && !response.body[:commit].nil?
      end

      def rest_data
        @result_index = @row_index = 0
        mapped_rest_data
      end

      def rest_data_with_id
        rest_data[:id] = mapped_rest_data[:self].split('/').last.to_i
        rest_data
      end

      private

      attr_reader :row_index, :result_index

      def mapped_rest_data
        data = response.body[:results][0][:data][result_index][:rest][row_index]
        data.is_a?(Array) ? data.first : data
      end
    end
  end
end