# frozen_string_literal: true

require 'faraday'
require 'set'

# Adapted from lostisland/faraday_middleware. Trimmed down to just the logic
# that we need for octokit.rb.
#
# https://github.com/lostisland/faraday_middleware/blob/138766e/lib/faraday_middleware/response/follow_redirects.rb

module Octokit
  module Middleware
    # Public: Exception thrown when the maximum amount of requests is exceeded.
    class RedirectLimitReached < Faraday::ClientError
      attr_reader :response

      def initialize(response)
        super "too many redirects; last one to: #{response['location']}"
        @response = response
      end
    end

    # Public: Follow HTTP 301, 302, 303, and 307 redirects.
    #
    # For HTTP 303, the original GET, POST, PUT, DELETE, or PATCH request gets
    # converted into a GET. For HTTP 301, 302, and 307, the HTTP method remains
    # unchanged.
    #
    # This middleware currently only works with synchronous requests; i.e. it
    # doesn't support parallelism.
    class FollowRedirects < Faraday::Middleware
      # HTTP methods for which 30x redirects can be followed
      ALLOWED_METHODS = Set.new %i[head options get post put patch delete]

      # HTTP redirect status codes that this middleware implements
      REDIRECT_CODES  = Set.new [301, 302, 303, 307]

      # Keys in env hash which will get cleared between requests
      ENV_TO_CLEAR    = Set.new %i[status response response_headers]

      # Default value for max redirects followed
      FOLLOW_LIMIT = 3

      # Regex that matches characters that need to be escaped in URLs, sans
      # the "%" character which we assume already represents an escaped
      # sequence.
      URI_UNSAFE = %r{[^\-_.!~*'()a-zA-Z\d;/?:@&=+$,\[\]%]}.freeze

      # Public: Initialize the middleware.
      #
      # options - An options Hash (default: {}):
      #           :limit               - A Integer redirect limit (default: 3).
      def initialize(app, options = {})
        super(app)
        @options = options

        @convert_to_get = Set.new [303]
      end

      def call(env)
        perform_with_redirection(env, follow_limit)
      end

      private

      def convert_to_get?(response)
        !%i[head options].include?(response.env[:method]) &&
          @convert_to_get.include?(response.status)
      end

      def perform_with_redirection(env, follows)
        request_body = env[:body]
        response = @app.call(env)

        response.on_complete do |response_env|
          if follow_redirect?(response_env, response)
            raise(RedirectLimitReached, response) if follows.zero?

            new_request_env = update_env(response_env, request_body, response)
            response = perform_with_redirection(new_request_env, follows - 1)
          end
        end
        response
      end

      def update_env(env, request_body, response)
        original_url = env[:url]
        env[:url] += safe_escape(response['location'])
        unless same_host?(original_url, env[:url])
          # HACK: Faraday’s Authorization middlewares don’t touch the request if the `Authorization` header is set.
          #       This is a workaround to drop authentication info.
          #       See https://github.com/octokit/octokit.rb/pull/1359#issuecomment-925609697
          env[:request_headers]['Authorization'] = 'dummy'
        end

        if convert_to_get?(response)
          env[:method] = :get
          env[:body] = nil
        else
          env[:body] = request_body
        end

        ENV_TO_CLEAR.each { |key| env.delete(key) }

        env
      end

      def follow_redirect?(env, response)
        ALLOWED_METHODS.include?(env[:method]) &&
          REDIRECT_CODES.include?(response.status)
      end

      def follow_limit
        @options.fetch(:limit, FOLLOW_LIMIT)
      end

      def same_host?(original_url, redirect_url)
        original_uri = Addressable::URI.parse(original_url)
        redirect_uri = Addressable::URI.parse(redirect_url)

        redirect_uri.host.nil? || original_uri.host == redirect_uri.host
      end

      # Internal: Escapes unsafe characters from a URL which might be a path
      # component only or a fully-qualified URI so that it can be joined onto a
      # URI:HTTP using the `+` operator. Doesn't escape "%" characters so to not
      # risk double-escaping.
      def safe_escape(uri)
        uri.to_s.gsub(URI_UNSAFE) do |match|
          "%#{match.unpack('H2' * match.bytesize).join('%').upcase}"
        end
      end
    end
  end
end
