# Copyright (c) 2023 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details.
# frozen_string_literal: true

require 'net/http'
require 'resolv'
require 'contrast/components/logger'
require 'contrast/utils/object_share'
require 'socket'

module Contrast
  module Utils
    # This module creates a Net::HTTP client base to be used by different services
    # All HTTP clients reporting to Telemetry or TS should inherit from this
    class NetHttpBase # rubocop:disable Metrics/ClassLength
      include Contrast::Components::Logger::InstanceMethods

      class << self
        # Last recorded error
        # @return [StandardError]
        def last_error
          @_last_error
        end

        # @param [StandardError]
        def last_error= error
          @_last_error = error
        end
      end

      # @return [String]
      attr_reader :client_name

      # This method initializes the Net::HTTP client we'll need. it will validate
      # the connection and make the first request. If connection is valid and response
      # is available then the open connection is returned.
      #
      # @param service_name [String] Name of service used in logging messages
      # @param url [String]
      # @param use_proxy [Boolean] flag used to indicate proxy connections [default = false]
      # @param use_custom_cert [Boolean] flag used to indicate whether the client is to use
      # self signed certificates provided by config [default = false]
      # @return [Net::HTTP, nil] Return open connection or nil
      def initialize_connection service_name, url, use_proxy: false, use_custom_cert: false
        Contrast::Utils::NetHttpBase.last_error = nil
        @client_name = service_name
        return unless (addr = retrieve_address(url))
        return unless (net_http_client = configure_new_client(addr, use_proxy, use_custom_cert))
        return unless client_started?(net_http_client)

        logger.debug("Starting #{ client_name } connection test")
        return unless connection_verified?(net_http_client, url)

        logger.debug('Client verified', service: client_name, url: url)
        net_http_client
      rescue StandardError => e
        Contrast::Utils::NetHttpBase.last_error = e
        return if client_name == Contrast::Agent::Telemetry::Client::SERVICE_NAME

        logger.error('Connection failed', e, service: client_name, url: url)
        nil
      end

      # Validates connection with assigned domain.
      # If connection is running, SSL certificate of the endpoint is valid, Ip address is resolvable
      # and response is received without peer's reset or refuse of connection, then validation returns true.
      #
      # @param client [Net::HTTP]
      # @param url [String]
      # @return [Boolean] true | false
      def connection_verified? client, url
        return @_connection_verified unless @_connection_verified.nil?
        return false if client.nil?

        ipaddr = get_ipaddr(client)
        response = client.request(Net::HTTP::Get.new(url))
        verify_cert = client.address.to_s.include?('localhost') ||
            OpenSSL::SSL.verify_certificate_identity(client.peer_cert, client.address)
        resolved = resolved?(client.address, ipaddr)
        @_connection_verified = if resolved && response && verify_cert
                                  true
                                else
                                  false
                                end
      rescue OpenSSL::SSL::SSLError, Resolv::ResolvError, Errno::ECONNRESET, Errno::ECONNREFUSED,
             Errno::ETIMEDOUT, Errno::ESHUTDOWN, Errno::EHOSTDOWN, Errno::EHOSTUNREACH, Errno::EISCONN,
             Errno::ECONNABORTED, Errno::ENETRESET, Errno::ENETUNREACH => e

        Contrast::Utils::NetHttpBase.last_error = e
        unless client_name == Contrast::Agent::Telemetry::Client::SERVICE_NAME
          logger.error("#{ client_name } connection failed", e.message)
        end
        @_connection_verified = false
      end

      private

      # Starts connection and return started status.
      #
      # @param client [Net::HTTP] client instance.
      # @return [Boolean] status indicates whether connection has started.
      def client_started? client
        return false unless client

        client.start
        client.started?
      end

      # @param url
      # @return [URI::Generic, nil]
      def retrieve_address url
        return unless (addr = URI(url))
        return if addr.host.nil? || addr.port.nil?
        return if addr.scheme != 'https' && !addr.host.to_s.include?('localhost')

        addr
      end

      # Assigns proxy and custom certificates if enabled, and initializes new client.
      #
      # @param addr [URI::Generic, nil]
      # @param use_proxy [Boolean] flag used to indicate proxy connections [default = false]
      # @param use_custom_cert [Boolean] flag used to indicate whether the client is to use
      # @return client [Net::HTTP, nil] initialized client.
      def configure_new_client addr, use_proxy, use_custom_cert
        # the proxy is enabled only if there is provided url even if the enable is set to true
        proxy_addr = URI(Contrast::API.proxy_url) if proxy_enabled?
        net_http_client = initialize_client(addr, proxy_addr, use_proxy, use_custom_cert)
        return if net_http_client.nil?

        net_http_client
      end

      # Resolves the address of the assigned domain to array of corresponding IPs (if more than one)
      # and runs a matcher to see if current connection IP is in the list.
      # This is called within #verify_connection, if called on it's own there will be no
      # error handling.
      #
      # @param address [String] Human friendly address of assigned domain
      # @param ipaddr [String] Machine friendly IP address of the assigned domain
      # @return[Boolean] true if both addresses are resolved | false if one of the addresses
      # is non-resolvable
      def resolved? address, ipaddr
        return @_resolved unless @_resolved.nil?

        @_resolved = if (addresses = Resolv.getaddresses(address))
                       addresses.any? { |addr| addr.include?(ipaddr) }
                     else
                       false
                     end
      end

      # if the configuration for the use of self-signed certificates is enabled
      # and required for this client then assign the files and keys to the client
      #
      # @param client [Net::HTTP]
      def assign_cert client
        if Contrast::API.certification_ca_file
          client.ca_file = OpenSSL::X509::Certificate.new(File.read(Contrast::API.certification_ca_file)).to_s
        elsif Contrast::API.certification_cert_file
          client.cert = OpenSSL::X509::Certificate.new(File.read(Contrast::API.certification_cert_file)).to_s
        elsif Contrast::API.certification_key_file
          client.key = OpenSSL::PKey::RSA.new(File.read(Contrast::API.certification_key_file)).to_s
        end
      rescue Errno::ENOENT => e
        Contrast::Utils::NetHttpBase.last_error = e
        unless client_name == Contrast::Agent::Telemetry::Client::SERVICE_NAME
          logger.error('Custom certificates failed', e.message)
        end
      end

      # sets default setting for client validation of certificates and
      # timeout options
      #
      # @param addr [URI::Generic] uri of assigned domain
      # @param proxy_addr [URI::Generic | nil] uri of proxy
      # @param use_proxy [Boolean] flag used to indicate proxy connections [default = false]
      # @param use_custom_cert [Boolean] flag used to indicate whether the client is to use
      # self signed certificates provided by config [default = false]
      # @return [Net::HTTP]
      def initialize_client addr, proxy_addr, use_proxy, use_custom_cert
        initialize_client = if proxy_enabled? && use_proxy
                              Net::HTTP.new(addr.host, nil, proxy_addr&.host, proxy_addr&.port)
                            else
                              Net::HTTP.new(addr.host, addr.port)
                            end
        return initialize_client if addr.host.to_s.include?('localhost') # TODO: RUBY-99999 allow http w/ localhost

        assign_cert(initialize_client) if use_custom_cert && Contrast::API.certification_enable
        initialize_client.use_ssl = true
        initialize_client.verify_mode = OpenSSL::SSL::VERIFY_PEER
        initialize_client.verify_depth = 5
        # open connection timeout in ms
        # if connection reaches timeout this will produce Net::OpenTimeout error
        initialize_client.open_timeout = 15
        # if we can't read the response or a chunk within time this will cause a
        # Net::ReadTimeout error when the request is made
        initialize_client.read_timeout = 15
        initialize_client
      end

      # Check if proxy is enabled
      #
      # @return @_proxy_enabled [Boolean] True if proxy is enabled and url is present else false
      def proxy_enabled?
        return @_proxy_enabled unless @_proxy_enabled.nil?

        @_proxy_enabled = Contrast::API.proxy_enable && !Contrast::API.proxy_url.nil?
      end

      # Retrieve the IP address from the client.
      #
      # @param client [Net::HTTP]
      # @return [String]
      def get_ipaddr client
        socket = TCPSocket.open(client.address, client.port)
        ipaddr = socket.peeraddr[3]
        socket.close
        ipaddr
      end
    end
  end
end