# encoding: UTF-8

# --
# Copyright (C) 2008-2011 10gen Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ++

require 'cgi'

module Mongo
  class URIParser

    USER_REGEX = /([-.\w:]+)/
    PASS_REGEX = /([^@,]+)/
    AUTH_REGEX = /(#{USER_REGEX}:#{PASS_REGEX}@)?/

    HOST_REGEX = /([-.\w]+)/
    PORT_REGEX = /(?::(\w+))?/
    NODE_REGEX = /((#{HOST_REGEX}#{PORT_REGEX},?)+)/

    PATH_REGEX = /(?:\/([-\w]+))?/

    MONGODB_URI_MATCHER = /#{AUTH_REGEX}#{NODE_REGEX}#{PATH_REGEX}/
    MONGODB_URI_SPEC = "mongodb://[username:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[database][?options]]"

    SPEC_ATTRS = [:nodes, :auths]
    OPT_ATTRS  = [:connect, :replicaset, :slaveok, :safe, :w, :wtimeout, :fsync, :journal, :connecttimeoutms, :sockettimeoutms, :wtimeoutms]

    OPT_VALID  = {:connect          => lambda {|arg| ['direct', 'replicaset', 'true', 'false', true, false].include?(arg)},
                  :replicaset       => lambda {|arg| arg.length > 0},
                  :slaveok          => lambda {|arg| ['true', 'false'].include?(arg)},
                  :safe             => lambda {|arg| ['true', 'false'].include?(arg)},
                  :w                => lambda {|arg| arg =~ /^\d+$/ },
                  :wtimeout         => lambda {|arg| arg =~ /^\d+$/ },
                  :fsync            => lambda {|arg| ['true', 'false'].include?(arg)},
                  :journal          => lambda {|arg| ['true', 'false'].include?(arg)},
                  :connecttimeoutms => lambda {|arg| arg =~ /^\d+$/ },
                  :sockettimeoutms  => lambda {|arg| arg =~ /^\d+$/ },
                  :wtimeoutms       => lambda {|arg| arg =~ /^\d+$/ }
                 }

    OPT_ERR    = {:connect          => "must be 'direct', 'replicaset', 'true', or 'false'",
                  :replicaset       => "must be a string containing the name of the replica set to connect to",
                  :slaveok          => "must be 'true' or 'false'",
                  :safe             => "must be 'true' or 'false'",
                  :w                => "must be an integer specifying number of nodes to replica to",
                  :wtimeout         => "must be an integer specifying milliseconds",
                  :fsync            => "must be 'true' or 'false'",
                  :journal          => "must be 'true' or 'false'",
                  :connecttimeoutms => "must be an integer specifying milliseconds",
                  :sockettimeoutms  => "must be an integer specifying milliseconds",
                  :wtimeoutms       => "must be an integer specifying milliseconds"
                 }

    OPT_CONV   = {:connect          => lambda {|arg| arg == 'false' ? false : arg}, # be sure to convert 'false' to FalseClass
                  :replicaset       => lambda {|arg| arg},
                  :slaveok          => lambda {|arg| arg == 'true' ? true : false},
                  :safe             => lambda {|arg| arg == 'true' ? true : false},
                  :w                => lambda {|arg| arg.to_i},
                  :wtimeout         => lambda {|arg| arg.to_i},
                  :fsync            => lambda {|arg| arg == 'true' ? true : false},
                  :journal          => lambda {|arg| arg == 'true' ? true : false},
                  :connecttimeoutms => lambda {|arg| arg.to_f / 1000 }, # stored as seconds
                  :sockettimeoutms  => lambda {|arg| arg.to_f / 1000 }, # stored as seconds
                  :wtimeoutms       => lambda {|arg| arg.to_i }
                 }

    attr_reader :auths, :connect, :replicaset, :slaveok, :safe, :w, :wtimeout, :fsync, :journal, :connecttimeoutms, :sockettimeoutms, :wtimeoutms

    # Parse a MongoDB URI. This method is used by Connection.from_uri.
    # Returns an array of nodes and an array of db authorizations, if applicable.
    #
    # @note Passwords can contain any character except for ','
    #
    # @param [String] uri The MongoDB URI string.
    # @param [Hash,nil] extra_opts Extra options. Will override anything already specified in the URI.
    #
    # @core connections
    def initialize(uri)
      if uri.start_with?('mongodb://')
        uri = uri[10..-1]
      else
        raise MongoArgumentError, "MongoDB URI must match this spec: #{MONGODB_URI_SPEC}"
      end

      hosts, opts = uri.split('?')
      parse_hosts(hosts)
      parse_options(opts)
      validate_connect
    end

    # Create a Mongo::Connection or a Mongo::ReplSetConnection based on the URI.
    #
    # @note Don't confuse this with attribute getter method #connect.
    #
    # @return [Connection,ReplSetConnection]
    def connection(extra_opts)
      opts = connection_options.merge! extra_opts
      if replicaset?
        ReplSetConnection.new(nodes, opts)
      else
        Connection.new(host, port, opts)
      end
    end

    # Whether this represents a replica set.
    # @return [true,false]
    def replicaset?
      replicaset.is_a?(String) || nodes.length > 1
    end

    # Whether to immediately connect to the MongoDB node[s]. Defaults to true.
    # @return [true, false]
    def connect?
      connect != false
    end

    # Whether this represents a direct connection.
    #
    # @note Specifying :connect => 'direct' has no effect... other than to raise an exception if other variables suggest a replicaset.
    #
    # @return [true,false]
    def direct?
      !replicaset?
    end

    # For direct connections, the host of the (only) node.
    # @return [String]
    def host
      nodes[0][0]
    end

    # For direct connections, the port of the (only) node.
    # @return [Integer]
    def port
      nodes[0][1].to_i
    end

    # Options that can be passed to Mongo::Connection.new or Mongo::ReplSetConnection.new
    # @return [Hash]
    def connection_options
      opts = {}

      if (@w || @journal || @wtimeout || @fsync || @wtimeoutms) && !@safe
        raise MongoArgumentError, "Safe must be true if w, journal, wtimeoutMS, or fsync is specified"
      end

      if @safe
        if @w || @journal || @wtimeout || @fsync || @wtimeoutms
          safe_opts = {}
          safe_opts[:w] = @w if @w
          safe_opts[:j] = @journal if @journal
          
          if @wtimeout
            warn "Using wtimeout in a URI is deprecated, please use wtimeoutMS. It will be removed in v2.0."
            safe_opts[:wtimeout] = @wtimeout
          end
          
          if @wtimeoutms
            safe_opts[:wtimeout] = @wtimeoutms
          end
          
          safe_opts[:fsync] = @fsync if @fsync
        else
          safe_opts = true
        end

        opts[:safe] = safe_opts
      end
      
      if @connecttimeoutms
        opts[:connect_timeout] = @connecttimeoutms
      end
      
      if @sockettimeoutms
        opts[:op_timeout] = @sockettimeoutms
      end

      if @slaveok
        if direct?
          opts[:slave_ok] = true
        else
          opts[:read] = :secondary
        end
      end

      if direct?
        opts[:auths] = auths
      end

      if replicaset.is_a?(String)
        opts[:name] = replicaset
      end

      opts[:connect] = connect?

      opts
    end

    def nodes
      if @nodes.length == 1
        @nodes
      else
        @nodes.collect {|node| "#{node[0]}:#{node[1]}"}
      end
    end

    private

    def parse_hosts(uri_without_proto)
      @nodes = []
      @auths = []

      matches = MONGODB_URI_MATCHER.match(uri_without_proto)

      if !matches
        raise MongoArgumentError, "MongoDB URI must match this spec: #{MONGODB_URI_SPEC}"
      end

      uname    = matches[2]
      pwd      = matches[3]
      hosturis = matches[4].split(',')
      db       = matches[8]

      hosturis.each do |hosturi|
        # If port is present, use it, otherwise use default port
        host, port = hosturi.split(':') + [Connection::DEFAULT_PORT]

        if !(port.to_s =~ /^\d+$/)
          raise MongoArgumentError, "Invalid port #{port}; port must be specified as digits."
        end

        port = port.to_i

        @nodes << [host, port]
      end

      if @nodes.empty?
        raise MongoArgumentError, "No nodes specified. Please ensure that you've provided at least one node."
      end

      if uname && pwd && db
        auths << {'db_name' => db, 'username' => uname, 'password' => pwd}
      elsif uname || pwd
        raise MongoArgumentError, "MongoDB URI must include username, password, "
          "and db if username and password are specified."
      end

      # The auths are repeated for each host in a replica set
      @auths *= hosturis.length
    end

    # This method uses the lambdas defined in OPT_VALID and OPT_CONV to validate
    # and convert the given options.
    def parse_options(string_opts)
      # initialize instance variables for available options
      OPT_VALID.keys.each { |k| instance_variable_set("@#{k}", nil) }

      string_opts ||= ''

      return if string_opts.empty?

      if string_opts.include?(';') and string_opts.include?('&')
        raise MongoArgumentError, "must not mix URL separators ; and &"
      end

      opts = CGI.parse(string_opts).inject({}) do |memo, (key, value)|
        value = value.first
        memo[key.downcase.to_sym] = value.strip.downcase
        memo
      end

      opts.each do |key, value|
        if !OPT_ATTRS.include?(key)
          raise MongoArgumentError, "Invalid Mongo URI option #{key}"
        end
        if OPT_VALID[key].call(value)
          instance_variable_set("@#{key}", OPT_CONV[key].call(value))
        else
          raise MongoArgumentError, "Invalid value #{value.inspect} for #{key}: #{OPT_ERR[key]}"
        end
      end
    end

    def validate_connect
      if replicaset? and @connect == 'direct'
        # Make sure the user doesn't specify something contradictory
        raise MongoArgumentError, "connect=direct conflicts with setting a replicaset name"
      end
    end
  end
end
