require "forwardable"
require "set"
require "stringio"

module Hurley
  class Client
    attr_reader :url
    attr_reader :header
    attr_writer :connection
    attr_reader :request_options
    attr_reader :ssl_options

    def initialize(endpoint = nil)
      @before_callbacks = []
      @after_callbacks = []
      @url = Url.parse(endpoint)
      @header = Header.new :user_agent => Hurley::USER_AGENT
      @connection = nil
      @request_options = RequestOptions.new
      @ssl_options = SslOptions.new
      yield self if block_given?
    end

    extend Forwardable
    def_delegators(:@url,
      :query,
      :scheme, :scheme=,
      :host, :host=,
      :port, :port=,
    )

    def connection
      @connection ||= Hurley.default_connection
    end

    def head(path, query = nil)
      req = request(:head, path)
      req.query.update(query) if query
      yield req if block_given?
      call(req)
    end

    def get(path, query = nil)
      req = request(:get, path)
      req.query.update(query) if query
      yield req if block_given?
      call(req)
    end

    def patch(path, body = nil, ctype = nil)
      req = request(:patch, path)
      req.body = body if body
      req.header[:content_type] = ctype if ctype
      yield req if block_given?
      call(req)
    end

    def put(path, body = nil, ctype = nil)
      req = request(:put, path)
      req.body = body if body
      req.header[:content_type] = ctype if ctype
      yield req if block_given?
      call(req)
    end

    def post(path, body = nil, ctype = nil)
      req = request(:post, path)
      req.body = body if body
      req.header[:content_type] = ctype if ctype
      yield req if block_given?
      call(req)
    end

    def delete(path, query = nil)
      req = request(:delete, path)
      req.query.update(query) if query
      yield req if block_given?
      call(req)
    end

    def options(path, query = nil)
      req = request(:options, path)
      req.query.update(query) if query
      yield req if block_given?
      call(req)
    end

    def call(request)
      call_with_redirects(request, [])
    end

    def before_call(name_or_callback = nil)
      @before_callbacks << (block_given? ?
        NamedCallback.for(name_or_callback, Proc.new) :
        NamedCallback.for(nil, name_or_callback))
    end

    def after_call(name_or_callback = nil)
      @after_callbacks << (block_given? ?
        NamedCallback.for(name_or_callback  , Proc.new) :
        NamedCallback.for(nil, name_or_callback))
    end

    def before_callbacks
      @before_callbacks.map(&:name)
    end

    def after_callbacks
      @after_callbacks.map(&:name)
    end

    def request(method, path)
      Request.new(method, Url.join(@url, path), @header.dup, nil, @request_options.dup, @ssl_options.dup)
    end

    private

    def call_with_redirects(request, via)
      @before_callbacks.each { |cb| cb.call(request) }

      request.prepare!
      response = connection.call(request)

      @after_callbacks.each { |cb| cb.call(response) }

      if response.automatically_redirect?(via)
        return call_with_redirects(response.location, via << request)
      end

      response.via = via
      response
    end
  end

  class Request < Struct.new(:verb, :url, :header, :body, :options, :ssl_options)
    extend Forwardable
    def_delegators(:url,
      :query,
      :scheme, :scheme=,
      :host, :host=,
      :port, :port=,
    )

    def options
      self[:options] ||= RequestOptions.new
    end

    def ssl_options
      self[:ssl_options] ||= SslOptions.new
    end

    def query_string
      url.query.to_query_string
    end

    def body_io
      return unless body

      if body.respond_to?(:read)
        body
      elsif body
        StringIO.new(body)
      end
    end

    def on_body(*statuses)
      @body_receiver = [statuses.empty? ? nil : statuses, Proc.new]
    end

    def inspect
      "#<%s %s %s>" % [
        self.class.name,
        verb.to_s.upcase,
        url.to_s,
      ]
    end

    def prepare!
      prepare_basic_auth!

      if body
        prepare_body!
      else
        return unless REQUIRED_BODY_VERBS.include?(verb)
      end

      prepare_content_length!
    end

    private

    def prepare_basic_auth!
      value = !header[:authorization] && url.basic_auth
      header[:authorization] = value if value
    end

    def prepare_body!
      ctype = nil
      case body
      when Query
        ctype, io = body.to_form
        self.body = io
      when Hash
        ctype, io = options.build_form(body)
        self.body = io
      end
      header[:content_type] ||= ctype || DEFAULT_TYPE
    end

    def prepare_content_length!
      if header.key?(:content_length) || header[:transfer_encoding] == CHUNKED
        return
      end

      if body
        if sizer = SIZE_METHODS.detect { |method| body.respond_to?(method) }
          header[:content_length] = body.send(sizer).to_i
        else
          header[:transfer_encoding] = CHUNKED
        end
      else
        header[:content_length] = 0
      end
    end

    def body_receiver
      @body_receiver ||= [nil, BodyReceiver.new]
    end

    DEFAULT_TYPE = "application/octet-stream".freeze
    CHUNKED = "chunked".freeze
    REQUIRED_BODY_VERBS = Set.new([:put, :post])
    SIZE_METHODS = [:bytesize, :length, :size]
  end

  class Response
    attr_reader :request
    attr_reader :header
    attr_accessor :body
    attr_accessor :status_code
    attr_writer :via

    def initialize(request, status_code = nil, header = nil)
      @request = request
      @status_code = status_code
      @header = header || Header.new
      @body = nil
      @receiver = nil
      @timing = nil
      @started_at = Time.now.to_f
      if block_given?
        yield self
        complete!
      end
    end

    def complete!
      @ended_at = Time.now.to_f
      if @receiver.respond_to?(:join)
        @body = @receiver.join
      end
    end

    def via
      @via ||= []
    end

    def location
      @location ||= begin
        return unless loc = @header[:location]
        verb = STATUS_FORCE_GET.include?(status_code) ? :get : request.verb
        statuses, receiver = request.send(:body_receiver)
        new_request = Request.new(verb, request.url.join(Url.parse(loc)), request.header, request.body, request.options, request.ssl_options)
        new_request.on_body(*statuses, &receiver) unless receiver.is_a?(Hurley::BodyReceiver)
        new_request
      end
    end

    def status_type
      @status_type ||= STATUS_TYPES.detect { |t| send("#{t}?") } || :other
    end

    def redirection?
      STATUS_REDIRECTION.include?(status_code)
    end

    def success?
      status_code > 199 && status_code < 300
    end

    def client_error?
      status_code > 399 && status_code < 500
    end

    def server_error?
      status_code > 499 && status_code < 600
    end

    def automatically_redirect?(previous_requests = nil)
      return false unless redirection?
      limit = request.options.redirection_limit.to_i
      limit > 0 && Array(previous_requests).size < limit
    end

    def receive_body(chunk)
      return if chunk.nil?

      if @receiver.nil?
        statuses, receiver = request.send(:body_receiver)
        @receiver = if statuses && !statuses.include?(@status_code)
          BodyReceiver.new
        else
          receiver
        end
      end

      @receiver.call(self, chunk)
    end

    def ms
      @timing ||= ((@ended_at - @started_at) * 1000).to_i
    end

    def inspect
      "#<%s %s %s == %d%s %dms>" % [
        self.class.name,
        @request.verb.to_s.upcase,
        @request.url.to_s,
        @status_code.to_i,
        @body ? " (#{@body.bytesize} bytes)" : nil,
        ms,
      ]
    end

    STATUS_TYPES = [:success, :redirection, :client_error, :server_error]
    STATUS_FORCE_GET = Set.new([301, 302, 303])
    STATUS_REDIRECTION = STATUS_FORCE_GET + [307, 308]
  end

  class BodyReceiver
    def initialize
      @chunks = []
    end

    def call(res, chunk)
      @chunks << chunk
    end

    def join
      @chunks.join
    end
  end

  class NamedCallback < Struct.new(:name, :callback)
    def self.for(name, callback)
      if callback.respond_to?(:name) && !name
        callback
      else
        new(name, callback)
      end
    end

    def name
      self[:name] ||= callback.inspect
    end

    def call(arg)
      callback.call(arg)
    end
  end
end
