# frozen_string_literal: true

#-------------------------------------------------------------------------
# # Copyright (c) Microsoft and contributors. All rights reserved.
#
# The MIT License(MIT)

# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files(the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions :

# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.

# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#--------------------------------------------------------------------------

require "uri"
require "azure/storage/common/client_options_error"
require "azure/storage/common/core/auth/anonymous_signer"

module Azure::Storage::Common
  module ClientOptions
    attr_accessor :ca_file, :ssl_version, :ssl_min_version, :ssl_max_version

    # Public: Reset options for [Azure::Storage::Common::Client]
    #
    # ==== Attributes
    #
    # * +options+                         - Hash | String. Optional parameters or storage connection string.
    #
    # ==== Options
    #
    # Accepted key/value pairs in options parameter are:
    #
    # * +:use_development_storage+        - TrueClass|FalseClass. Whether to use storage emulator.
    # * +:development_storage_proxy_uri+  - String. Used with +:use_development_storage+ if emulator is hosted other than localhost.
    # * +:storage_connection_string+      - String. The storage connection string.
    # * +:storage_account_name+           - String. The name of the storage account.
    # * +:storage_access_key+             - Base64 String. The access key of the storage account.
    # * +:storage_sas_token+              - String. The signed access signature for the storage account or one of its service.
    # * +:storage_blob_host+              - String. Specified Blob serivce endpoint or hostname
    # * +:storage_table_host+             - String. Specified Table serivce endpoint or hostname
    # * +:storage_queue_host+             - String. Specified Queue serivce endpoint or hostname
    # * +:storage_dns_suffix+             - String. The suffix of a regional Storage Serivce, to
    # * +:default_endpoints_protocol+     - String. http or https
    # * +:use_path_style_uri+             - String. Whether use path style URI for specified endpoints
    # * +:ca_file+                        - String. File path of the CA file if having issue with SSL
    # * +:ssl_version+                    - Symbol. The ssl version to be used, sample: :TLSv1_1, :TLSv1_2, for the details, see https://github.com/ruby/openssl/blob/master/lib/openssl/ssl.rb
    # * +:ssl_min_version+                - Symbol. The min ssl version supported, only supported in Ruby 2.5+
    # * +:ssl_max_version+                - Symbol. The max ssl version supported, only supported in Ruby 2.5+
    #
    # The valid set of options include:
    # * Storage Emulator: +:use_development_storage+ required, +:development_storage_proxy_uri+ optionally
    # * Storage account name and key: +:storage_account_name+ and +:storage_access_key+ required, set +:storage_dns_suffix+ necessarily
    # * Storage account name and SAS token: +:storage_account_name+ and +:storage_sas_token+ required, set +:storage_dns_suffix+ necessarily
    # * Specified hosts and SAS token: At least one of the service host and SAS token. It's up to user to ensure the SAS token is suitable for the serivce
    # * Anonymous Blob: only +:storage_blob_host+, if it is to only access blobs within a container
    #
    # Additional notes:
    # * Specified hosts can be set when use account name with access key or sas token
    # * +:default_endpoints_protocol+ can be set if the scheme is not specified in hosts
    # * Storage emulator always use path style URI
    #
    # When empty options are given, it will try to read settings from Environment Variables. Refer to [Azure::Storage::Common:ClientOptions.env_vars_mapping] for the mapping relationship
    #
    # @return [Azure::Storage::Common::Client]
    def reset!(options = {})
      if options.is_a? String
        options = parse_connection_string(options)
      elsif options.is_a? Hash
        # When the options are provided via singleton setup: Azure::Storage.setup()
        options = setup_options if options.length == 0

        options = parse_connection_string(options[:storage_connection_string]) if options[:storage_connection_string]
      end

      # Load from environment when no valid input
      options = load_env if options.length == 0

      @ca_file = options.delete(:ca_file)
      @ssl_version = options.delete(:ssl_version)
      @ssl_min_version = options.delete(:ssl_min_version)
      @ssl_max_version = options.delete(:ssl_max_version)
      @options = filter(options)
      self.send(:reset_config!, @options) if self.respond_to?(:reset_config!)
      self
    end

    # Check if this client is configured with the same options
    def same_options?(opts)
      opts.length == 0 || opts.hash == options.hash
    end

    # The options after validated and normalized
    #
    # @return [Hash]
    def options
      @options ||= {}
    end

    # The valid options for the storage client
    #
    # @return [Array]
    def self.valid_options
      @valid_options ||= [
        :use_development_storage,
        :development_storage_proxy_uri,
        :storage_account_name,
        :storage_access_key,
        :storage_connection_string,
        :storage_sas_token,
        :storage_blob_host,
        :storage_table_host,
        :storage_queue_host,
        :storage_file_host,
        :storage_dns_suffix,
        :default_endpoints_protocol,
        :use_path_style_uri
      ]
    end

    # The mapping between Storage Environment Variables and the options name
    #
    # @return [Hash]
    def self.env_vars_mapping
      @env_vars_mapping ||= {
        "EMULATED" => :use_development_storage,
        "AZURE_STORAGE_ACCOUNT" => :storage_account_name,
        "AZURE_STORAGE_ACCESS_KEY" => :storage_access_key,
        "AZURE_STORAGE_CONNECTION_STRING" => :storage_connection_string,
        "AZURE_STORAGE_BLOB_HOST" => :storage_blob_host,
        "AZURE_STORAGE_TABLE_HOST" => :storage_table_host,
        "AZURE_STORAGE_QUEUE_HOST" => :storage_queue_host,
        "AZURE_STORAGE_FILE_HOST" => :storage_file_host,
        "AZURE_STORAGE_SAS_TOKEN" => :storage_sas_token,
        "AZURE_STORAGE_DNS_SUFFIX" => :storage_dns_suffix
      }
    end

    # The mapping between Storage Connection String items and the options name
    #
    # @return [Hash]
    def self.connection_string_mapping
      @connection_string_mapping ||= {
        "UseDevelopmentStorage" => :use_development_storage,
        "DevelopmentStorageProxyUri" => :development_storage_proxy_uri,
        "DefaultEndpointsProtocol" => :default_endpoints_protocol,
        "AccountName" => :storage_account_name,
        "AccountKey" => :storage_access_key,
        "BlobEndpoint" => :storage_blob_host,
        "TableEndpoint" => :storage_table_host,
        "QueueEndpoint" => :storage_queue_host,
        "FileEndpoint" => :storage_file_host,
        "SharedAccessSignature" => :storage_sas_token,
        "EndpointSuffix" => :storage_dns_suffix
      }
    end

    private

      def method_missing(method_name, *args, &block)
        return super unless options.key? method_name
        options[method_name]
      end

      def filter(opts = {})
        results = {}

        # P1 - develpoment storage
        begin
          results = validated_options(opts,
                                      required: [:use_development_storage],
                                      optional: [:development_storage_proxy_uri])
          results[:use_development_storage] = true
          proxy_uri = results[:development_storage_proxy_uri] ||= StorageServiceClientConstants::DEV_STORE_URI
          results.merge!(storage_account_name: StorageServiceClientConstants::DEVSTORE_STORAGE_ACCOUNT,
                          storage_access_key: StorageServiceClientConstants::DEVSTORE_STORAGE_ACCESS_KEY,
                          storage_blob_host: "#{proxy_uri}:#{StorageServiceClientConstants::DEVSTORE_BLOB_HOST_PORT}",
                          storage_table_host: "#{proxy_uri}:#{StorageServiceClientConstants::DEVSTORE_TABLE_HOST_PORT}",
                          storage_queue_host: "#{proxy_uri}:#{StorageServiceClientConstants::DEVSTORE_QUEUE_HOST_PORT}",
                          storage_file_host: "#{proxy_uri}:#{StorageServiceClientConstants::DEVSTORE_FILE_HOST_PORT}",
                          use_path_style_uri: true)
          return results
        rescue InvalidOptionsError
        end

        # P2 - explicit hosts with account connection string
        begin
          results = validated_options(opts,
                                      required: [:storage_connection_string],
                                      optional: [:use_path_style_uri])
          results[:use_path_style_uri] = results.key?(:use_path_style_uri)
          normalize_hosts(results)
          return results
        rescue InvalidOptionsError
        end

        # P3 - account name and key or sas with default hosts or an end suffix
        begin
          results = validated_options(opts,
                                      required: [:storage_account_name],
                                      only_one: [:storage_access_key, :storage_sas_token, :signer],
                                      optional: [:default_endpoints_protocol, :storage_dns_suffix])
          protocol = results[:default_endpoints_protocol] ||= StorageServiceClientConstants::DEFAULT_PROTOCOL
          suffix = results[:storage_dns_suffix] ||= StorageServiceClientConstants::DEFAULT_ENDPOINT_SUFFIX
          account = results[:storage_account_name]
          results.merge!(storage_blob_host: "#{protocol}://#{account}.#{ServiceType::BLOB}.#{suffix}",
                          storage_table_host: "#{protocol}://#{account}.#{ServiceType::TABLE}.#{suffix}",
                          storage_queue_host: "#{protocol}://#{account}.#{ServiceType::QUEUE}.#{suffix}",
                          storage_file_host: "#{protocol}://#{account}.#{ServiceType::FILE}.#{suffix}",
                          use_path_style_uri: false)
          return results
        rescue InvalidOptionsError
        end

        # P4 - explicit hosts with account name and key
        begin
          results = validated_options(opts,
                                      required: [:storage_account_name, :storage_access_key],
                                      at_least_one: [:storage_blob_host, :storage_table_host, :storage_file_host, :storage_queue_host],
                                      optional: [:use_path_style_uri, :default_endpoints_protocol])
          results[:use_path_style_uri] = results.key?(:use_path_style_uri)
          normalize_hosts(results)
          return results
        rescue InvalidOptionsError
        end

        # P5 - anonymous or sas only for one or more particular services, options with account name/key + hosts should be already validated in P4
        begin
          results = validated_options(opts,
                                      at_least_one: [:storage_blob_host, :storage_table_host, :storage_file_host, :storage_queue_host],
                                      optional: [:use_path_style_uri, :default_endpoints_protocol, :storage_sas_token])
          results[:use_path_style_uri] = results.key?(:use_path_style_uri)
          normalize_hosts(results)
          # Adds anonymous signer if no sas token
          results[:signer] = Azure::Storage::Common::Core::Auth::AnonymousSigner.new unless results.key?(:storage_sas_token)
          return results
        rescue InvalidOptionsError
        end

        # P6 - account name and key or sas with explicit hosts
        begin
          results = validated_options(opts,
                                      required: [:storage_account_name],
                                      only_one: [:storage_access_key, :storage_sas_token],
                                      at_least_one: [:storage_blob_host, :storage_table_host, :storage_file_host, :storage_queue_host])
          results[:use_path_style_uri] = results.key?(:use_path_style_uri)
          normalize_hosts(results)
          return results
        rescue InvalidOptionsError
        end

        raise InvalidOptionsError, "options provided are not valid set: #{opts}" # wrong opts if move to this line
      end

      def normalize_hosts(options)
        if options[:default_endpoints_protocol]
          [:storage_blob_host, :storage_table_host, :storage_file_host, :storage_queue_host].each do |k|
            if options[k]
              raise InvalidOptionsError, "Explict host cannot contain scheme if default_endpoints_protocol is set." if options[k] =~ /^https?/
              options[k] = "#{options[:default_endpoints_protocol]}://#{options[k]}"
            end
          end
        end
      end

      def is_base64_encoded
        Proc.new do |i|
          i.is_a?(String) && i =~ /^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=|[A-Za-z0-9+\/]{4})$/
        end
      end

      def is_url
        Proc.new do |i|
          i = "http://" + i unless i =~ /\Ahttps?:\/\//
          i =~ URI::DEFAULT_PARSER.make_regexp(["http", "https"])
        end
      end

      def is_true
        Proc.new { |i| i == true || (i.is_a?(String) && i.downcase == "true") }
      end

      def is_non_empty_string
        Proc.new { |i| i && i.is_a?(String) && i.strip.length }
      end

      def validated_options(opts, requirements = {})
        raise InvalidOptionsError, 'nil is not allowed for option\'s value' if opts.values.any? { |v| v == nil }
        required = requirements[:required] || []
        at_least_one = requirements[:at_least_one] || []
        only_one = requirements[:only_one] || []
        optional = requirements[:optional] || []

        raise InvalidOptionsError, "Not all required keys are provided: #{required}" if required.any? { |k| !opts.key? k }
        raise InvalidOptionsError, "Only one of #{only_one} is required" unless only_one.length == 0 || only_one.count { |k| opts.key? k } == 1
        raise InvalidOptionsError, "At least one of #{at_least_one} is required" unless at_least_one.length == 0 || at_least_one.any? { |k| opts.key? k }

        @@option_validators ||= {
          use_development_storage: is_true,
          development_storage_proxy_uri: is_url,
          storage_account_name: lambda { |i| i.is_a?(String) },
          storage_access_key: is_base64_encoded,
          storage_sas_token: lambda { |i| i.is_a?(String) },
          storage_blob_host: is_url,
          storage_table_host: is_url,
          storage_queue_host: is_url,
          storage_file_host: is_url,
          storage_dns_suffix: is_url,
          default_endpoints_protocol: lambda { |i| ["http", "https"].include? i.downcase },
          use_path_style_uri: is_true,
          signer: lambda { |i| i.is_a? Azure::Core::Auth::Signer} 
        }

        valid_options = required + at_least_one + only_one + optional
        results = {}

        opts.each do |k, v|
          raise InvalidOptionsError, "#{k} is not included in valid options" unless valid_options.length == 0 || valid_options.include?(k)
          unless @@option_validators.key?(k) && @@option_validators[k].call(v)
            raise InvalidOptionsError, "#{k} is invalid"
          end
          results[k] = v
        end
        results
      end

      def load_env
        cs = ENV["AZURE_STORAGE_CONNECTION_STRING"]
        return parse_connection_string(cs) if cs

        opts = {}
        ClientOptions.env_vars_mapping.each { |k, v| opts[v] = ENV[k] if ENV[k] }
        opts
      end

      def parse_connection_string(connection_string)
        opts = {}
        connection_string.split(";").each do |i|
          e = i.index("=") || -1
          raise InvalidConnectionStringError, Azure::Storage::Common::Core::SR::INVALID_CONNECTION_STRING if e < 0 || e == i.length - 1
          key, value = i[0..e - 1], i[e + 1..i.length - 1]
          raise InvalidConnectionStringError, Azure::Storage::Common::Core::SR::INVALID_CONNECTION_STRING_BAD_KEY % key unless ClientOptions.connection_string_mapping.key? key
          raise InvalidConnectionStringError, Azure::Storage::Common::Core::SR::INVALID_CONNECTION_STRING_EMPTY_KEY % key if value.length == 0
          raise InvalidConnectionStringError, Azure::Storage::Common::Core::SR::INVALID_CONNECTION_STRING_DUPLICATE_KEY % key if opts.key? key
          opts[ClientOptions.connection_string_mapping[key]] = value
        end
        raise InvalidConnectionStringError, Azure::Storage::Common::Core::SR::INVALID_CONNECTION_STRING if opts.length == 0

        opts
      end
  end
end
