# Encoding: utf-8
#
# Copyright 2018 Google LLC
#
# 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
#
#     https://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.
#
# Interceptor to log outgoing requests and incoming responses.

require 'google/ads/google_ads/api_versions'
require 'google/ads/google_ads/status_decoder'
require 'grpc/generic/interceptors'
require 'json'

module Google
  module Ads
    module GoogleAds
      module Interceptors
        class LoggingInterceptor < GRPC::ClientInterceptor

          HEADERS_TO_MASK = [:"developer-token"]
          SEARCH_RESPONSE_FIELDS_TO_MASK = %w[
            emailAddress
            inviterUserEmailAddress
            userEmail
          ]
          SEARCH_REQUEST_MASK = /
            customer_user_access.email_address|
            customer_user_access_invitation.email_address|
            change_event.user_email|
            local_services_lead.contact_details.phone_number|
            local_services_lead.contact_details.email|
            local_services_lead.contact_details.consumer_name|
            local_services_lead_conversation.message_details.text
          /x

          MASK_REPLACEMENT = "REDACTED"

          def initialize(logger)
            # Don't propagate args, parens are necessary
            super()
            @logger = logger
          end

          def request_response(request:, call:, method:, metadata: {})
            begin
              response = yield

              @logger.info { build_summary_message(request, call, method, false) }
              @logger.debug { build_request_message(request, call, method, metadata) }
              @logger.debug { build_success_response_message(response) }
              if response.respond_to?(:partial_failure_error) && response.partial_failure_error
                @logger.debug { build_partial_failure_message(response) }
              end
              response
            rescue Exception
              @logger.warn { build_summary_message(request, call, method, true) }
              @logger.info { build_request_message(request, call, method, metadata) }
              @logger.info { build_error_response_message }
              raise
            end
          end

          def server_streamer(request:, call:, method:, metadata: {})
            begin
              @logger.info { build_summary_message(request, call, method, false) }
              responses = yield
              Enumerator.new do |y|
                responses.each { |response|
                  @logger.debug { build_request_message(request, call, method, metadata) }
                  @logger.debug { build_success_response_message(response) }
                  if response.respond_to?(:partial_failure_error) && response.partial_failure_error
                    @logger.debug { build_partial_failure_message(response) }
                  end
                  y << response
                }
                @logger.debug {
                  request_id = call
                    .instance_variable_get(:@wrapped)
                    .instance_variable_get(:@call)
                    .trailing_metadata["request-id"]
                  "Request ID for preceding streaming request: #{request_id}"
                }
              rescue Exception
                handle_error(request, call, method, metadata)
              end
            rescue Exception
              handle_error(request, call, method, metadata)
            end
          end

          private

          def handle_error(request, call, method, metadata)
            @logger.warn { build_summary_message(request, call, method, true) }
            @logger.info { build_request_message(request, call, method, metadata) }
            @logger.info { build_error_response_message }
            raise
          end

          def build_partial_failure_message(response)
            errors = StatusDecoder.decode(
              response.partial_failure_error
            )
            errors.reduce("Partial failure errors: ") do |accum, error|
              accum += error.to_json + "\n"
              accum
            end
          end

          def build_error_response_message
            exception = $!
            response_message = ""
            response_message << "Incoming response (errors): \n"
            response_message << error_details(exception).join
            response_message
          end

          def error_details(exception)
            last_user_line = CallerFilter.first_non_google_ads_line
            exception_details = [
              "  ",
              exception.class,
              "(",
              exception.message,
              "): "
            ]

            case exception
            when Google::Ads::GoogleAds::Errors::GoogleAdsError
              exception.failure.errors.each do |error|
                exception_details << error.message
              end
            when GRPC::InvalidArgument
              exception_details << exception.details
            end

            exception_details << [
              "\n",
              "  called from: ",
              last_user_line
            ]

            exception_details
          end

          def build_success_response_message(response)
            "\nResponse\n--------\n" \
              "Response: #{sanitize_message(response).to_json}"
          end

          def build_request_message(request, call, method, metadata)
            # calling #to_json on some protos (specifically those with non-UTF8
            # encodable byte values) causes a segfault, however #inspect works
            # so we check if the proto contains a bytevalue, and if it does
            # we #inspect instead of #to_json
            request_inspect = if use_bytes_inspect?(request)
                                request.inspect
                              else
                                sanitize_message(request).to_json
                              end
            "\nRequest\n-------\n" \
              "Method: #{method}\n" \
              "Host: #{call.instance_variable_get(:@wrapped).instance_variable_get(:@peer)}\n" \
              "Headers: #{sanitize_headers(metadata).to_json}\n" \
              "Request: #{request_inspect}"
          end

          def sanitize_headers(metadata)
            metadata = metadata.clone
            HEADERS_TO_MASK.each do |header|
              metadata[header] = MASK_REPLACEMENT
            end
            metadata
          end

          def clone_to_json(message)
            JSON.parse(message.to_json)
          end

          def sanitize_message(message)
            message_class = message.class.to_s.split("::").last
            if %w[SearchGoogleAdsStreamResponse SearchGoogleAdsResponse].include?(
                message_class)
              # Sanitize all known sensitive fields across all search responses.
              message = clone_to_json(message)
              message["fieldMask"].split(",").each do |path|
                if SEARCH_RESPONSE_FIELDS_TO_MASK.include?(path.split(".").last)
                  message["results"]&.each do |result|
                    sanitize_field(result, path)
                  end
                end
              end
              message
            elsif %w[SearchGoogleAdsRequest SearchGoogleAdsStreamRequest].include?(
                message_class)
              if SEARCH_REQUEST_MASK === message.query
                message = clone_to_json(message)
                message["query"] = MASK_REPLACEMENT
              end
              message
            elsif "CustomerUserAccess" == message_class
              # Sanitize sensitive fields specific to CustomerUserAccess get requests.
              message = clone_to_json(message)
              sanitize_customer_user_access(message)
            elsif "MutateCustomerUserAccessRequest" == message_class
              # Sanitize sensitive fields when mutating a CustomerUserAccess.
              message = clone_to_json(message)
              if message.include?("operation") && message["operation"].include?("update")
                message["operation"]["update"] =
                  sanitize_customer_user_access(message["operation"]["update"])
              end
              message
            elsif "CustomerUserAccessInvitation" == message_class
              # Sanitize sensitive fields specific to CustomerUserAccessInvitation get requests.
              message = clone_to_json(message)
              sanitize_customer_user_access_invitation(message)
            elsif "MutateCustomerUserAccessInvitationRequest" == message_class
              # Sanitize sensitive fields when mutating a CustomerUserAccessInvitation.
              message = clone_to_json(message)
              if message.include?("operation") && message["operation"].include?("create")
                message["operation"]["create"] =
                  sanitize_customer_user_access_invitation(message["operation"]["create"])
              end
              message
            elsif "CreateCustomerClientRequest" == message_class
              # Sanitize sensitive fields when creating a CustomerClient.
              message = clone_to_json(message)
              if message.include?("emailAddress")
                message["emailAddress"] = MASK_REPLACEMENT
              end
              message
            elsif "LocalServicesLead" == message_class
              # Sanitize sensitive fields when creating a LocalServiceLead.
              message = clone_to_json(message)
              sanitize_local_services_lead(message)
            elsif "LocalServicesLeadConversation" == message_class
              # Sanitize sensitive fields when creating a LocalServicesLeadConversation.
              message = clone_to_json(message)
              sanitize_local_services_lead_conversation(message)
            else
              message
            end
          end

          def sanitize_local_services_lead_conversation(message)
            if message.include?("messageDetails")
              if message["messageDetails"].include?("text")
                message["messageDetails"]["text"] = MASK_REPLACEMENT
              end
            end
            message
          end

          def sanitize_local_services_lead(message)
            if message.include?("contactDetails")
              if message["contactDetails"].include?("email")
                message["contactDetails"]["email"] = MASK_REPLACEMENT
              end
              if message["contactDetails"].include?("phoneNumber")
                message["contactDetails"]["phoneNumber"] = MASK_REPLACEMENT
              end
              if message["contactDetails"].include?("consumerName")
                message["contactDetails"]["consumerName"] = MASK_REPLACEMENT
              end
            end
            message
          end

          def sanitize_customer_user_access(message)
            if message.include?("emailAddress")
              message["emailAddress"] = MASK_REPLACEMENT
            end
            if message.include?("inviterUserEmailAddress")
              message["inviterUserEmailAddress"] = MASK_REPLACEMENT
            end
            message
          end

          def sanitize_customer_user_access_invitation(message)
            if message.include?("emailAddress")
              message["emailAddress"] = MASK_REPLACEMENT
            end
            message
          end

          def sanitize_field(object, path)
            split_path = path.split(".")
            target_field = split_path.last
            split_path.inject(object) do |obj, field|
              if obj.include?(target_field)
                obj[target_field] = MASK_REPLACEMENT
                break
              end
              obj[field]
            end
          end

          def build_summary_message(request, call, method, is_fault)
            customer_id = if request.respond_to?(:customer_id)
              request.customer_id
            elsif request.respond_to?(:resource_name)
              segments = request.resource_name.split('/')
              segments[1] if segments[0] == "customers"
            else
              "N/A"
            end

            is_fault_string = if is_fault
                                "yes"
                              else
                                "no"
                              end

            wrapped_var = call.instance_variable_get(:@wrapped)

            trailing_metadata = wrapped_var
              .instance_variable_get(:@call)
              .trailing_metadata

            request_id = "N/A"
            request_id = trailing_metadata["request-id"] if trailing_metadata

            [
              "CID: #{customer_id}",
              "Host: #{wrapped_var.peer}",
              "Method: #{method}",
              "IsFault: #{is_fault_string}",
              "Request ID: #{request_id}",
            ].join(", ")
          end

          def response_error_from_detail(detail)
            detail.errors.map.with_index { |error, i|
              "Error #{i + 1}: #{error.to_json}"
            }.join("\n")
          end

          def use_bytes_inspect?(request)
            @cycle_finder = CycleFinder.new
            contains_bytes_field?(request.class.descriptor)
          end

          def contains_bytes_field?(descriptor)
            return false if descriptor.nil?
            return false if @cycle_finder.is_cycle?(descriptor)

            @cycle_finder.add_object(descriptor)

            descriptor.map { |x| x.type == :bytes || (x.type == :message && contains_bytes_field?(x.subtype)) }.any?
          end

          def interesting_error_classes
            @interesting_error_classes ||= Google::Ads::GoogleAds::Errors.namespaces.map do |namespace|
              namespace.const_get(:GoogleAdsFailure)
            end
          end

          class CycleFinder
            def initialize
              @objects_seen = Set.new
            end

            def is_cycle?(object)
              @objects_seen.include?(object.object_id)
            end

            def add_object(object)
              @objects_seen.add(object.object_id)
            end
          end
        end
      end
    end
  end
end
