require 'set'

module Roadie
  # @api private
  # Class that handles URL generation
  #
  # URL generation is all about converting relative URLs into absolute URLS
  # according to the given options. It is written such as absolute URLs will
  # get passed right through, so all URLs could be passed through here.
  class UrlGenerator
    attr_reader :url_options

    # Create a new instance with the given URL options.
    #
    # Initializing without a host setting raises an error, as do unknown keys.
    #
    # @param [Hash] url_options
    # @option url_options [String] :host (required)
    # @option url_options [String, Integer] :port
    # @option url_options [String] :path root path
    # @option url_options [String] :scheme URL scheme ("http" is default)
    # @option url_options [String] :protocol alias for :scheme
    def initialize(url_options)
      raise ArgumentError, "No URL options were specified" unless url_options
      raise ArgumentError, "No :host was specified; options are: #{url_options.inspect}" unless url_options[:host]
      validate_options url_options

      @url_options = url_options
      @root_uri = build_root_uri
    end

    # Generate an absolute URL from a relative URL.
    #
    # If the passed path is already an absolute URL, it will be returned as-is.
    # If passed a blank path, the "root URL" will be returned. The root URL is
    # the URL that the {#url_options} would generate by themselves.
    #
    # An optional base can be specified. The base is another relative path from
    # the root that specifies an "offset" from which the path was found in. A
    # common use-case is to convert a relative path found in a stylesheet which
    # resides in a subdirectory.
    #
    # @example Normal conversions
    #   generator = Roadie::UrlGenerator.new host: "foo.com", scheme: "https"
    #   generator.generate_url("bar.html") # => "https://foo.com/bar.html"
    #   generator.generate_url("/bar.html") # => "https://foo.com/bar.html"
    #   generator.generate_url("") # => "https://foo.com"
    #
    # @example Conversions with a base
    #   generator = Roadie::UrlGenerator.new host: "foo.com", scheme: "https"
    #   generator.generate_url("../images/logo.png", "/css") # => "https://foo.com/images/logo.png"
    #   generator.generate_url("../images/logo.png", "/assets/css") # => "https://foo.com/assets/images/logo.png"
    #
    # @param [String] base The base which the relative path comes from
    # @return [String] an absolute URL
    def generate_url(path, base = "/")
      return root_uri.to_s if path.nil? or path.empty?
      return path if path_is_absolute?(path)

      combine_segments(root_uri, base, path).to_s
    end

    private
    attr_reader :root_uri

    def build_root_uri
      path = make_absolute url_options[:path]
      port = parse_port url_options[:port]
      scheme = normalize_scheme(url_options[:scheme] || url_options[:protocol])
      URI::Generic.build(scheme: scheme, host: url_options[:host], port: port, path: path)
    end

    def combine_segments(root, base, path)
      new_path = apply_base(base, path)
      if root.path
        new_path = File.join(root.path, new_path)
      end
      root.merge(new_path)
    end

    def apply_base(base, path)
      if path[0] == "/"
        path
      else
        File.join(base, path)
      end
    end

    # Strip :// from any scheme, if present
    def normalize_scheme(scheme)
      return 'http' unless scheme
      scheme.to_s[/^\w+/]
    end

    def parse_port(port)
      (port ? port.to_i : port)
    end

    def make_absolute(path)
      if path.nil? || path[0] == "/"
        path
      else
        "/#{path}"
      end
    end

    def path_is_absolute?(path)
      # Ruby's URI is pretty unforgiving, but roadie aims to be. Don't involve
      # URI for URLs that's easy to determine to be absolute.
      # URLs starting with a scheme (http:, data:) are absolute, as is URLs
      # start start with double slashes (//css/app.css).
      path =~ %r{^(\w+:|//)} || !parse_path(path).relative?
    end

    def parse_path(path)
      URI.parse(path)
    rescue URI::InvalidURIError => error
      raise InvalidUrlPath.new(path, error)
    end

    VALID_OPTIONS = Set[:host, :port, :path, :protocol, :scheme].freeze

    def validate_options(options)
      keys = Set.new(options.keys)
      unless keys.subset? VALID_OPTIONS
        raise ArgumentError, "Passed invalid options: #{(keys - VALID_OPTIONS).to_a}, valid options are: #{VALID_OPTIONS.to_a}"
      end
    end
  end
end