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

require 'contrast/agent/telemetry/exception'

module Contrast
  module Logger
    # Our decorator for the Ougai logger allowing for the catching, creating and saving Telemetry exceptions
    module AliasedLogging
      ALIASED_WARN = 'warn'.cs__freeze
      ALIASED_ERROR = 'error'.cs__freeze
      ALIASED_FATAL = 'fatal'.cs__freeze

      # @param message [String] The message to log. Use default_message if not specified.
      # @param exception [Exception] The exception or the error
      # @param data [Object] Any structured data
      def warn message = nil, exception = nil, data = nil, &block
        # build Telemetry Exclusion
        build_exception(ALIASED_WARN, message, exception, data)
        super(message, exception, data, &block)
      end

      # @param message [String] The message to log. Use default_message if not specified.
      # @param exception [Exception] The exception or the error
      # @param data [Object] Any structured data
      def error message = nil, exception = nil, data = nil, &block
        # build Telemetry Exclusion
        build_exception(ALIASED_ERROR, message, exception, data)
        super(message, exception, data, &block)
      end

      # @param message [String] The message to log. Use default_message if not specified.
      # @param exception [Exception] The exception or the error
      # @param data [Object] Any structured data
      def fatal message = nil, exception = nil, data = nil, &block
        # build Telemetry Exclusion
        build_exception(ALIASED_FATAL, message, exception, data)
        super(message, exception, data, &block)
      end

      private

      # There's a weird circular import in our code that we don't have time to untangle. This is 100% bad code and
      # I'm sorry to the future team, but this is all we got.
      #
      # If the configuration we need is not available, we'll skip out for now - it means the agent isn't ready. We
      # won't get telemetry exceptions at this point, but that means the agent hasn't initialized and there's a
      # chance we're not supposed to. Essentially, we fail closed.
      #
      # Once we have the agent initialized, we'll use the value to check. Since it cannot change once set, we'll use
      # the saved value.
      #
      # - HM
      #
      # @return [Boolean]
      def buildable?
        if @_buildable.nil?
          return false unless defined?(Contrast) &&
              defined?(Contrast::Agent) &&
              defined?(Contrast::Agent::Telemetry)

          @_buildable = Contrast::Agent::Telemetry.exceptions_enabled?
        end
        @_buildable
      end

      # @param type [ALIASED_FATAL, ALIASED_ERROR, ALIASED_WARN] the type of error, used to indicate the function used
      #   for logging
      # @param message [String] the exception message
      # @param exception [Exception] The exception or error
      # @param data [Object] Any structured data
      def build_exception type, message = nil, exception = nil, data = nil
        return unless buildable?

        caller_idx = wrapped_caller_locations&.find_index { |stack| stack.to_s.include?(type) } || 0
        caller_idx += 1
        caller = wrapped_caller_locations[caller_idx]
        stack_frame_type = obfuscate_type(caller)
        stack_frame_function = caller.label
        key = "#{ stack_frame_type }|#{ stack_frame_function }|#{ message }"
        if Contrast::TELEMETRY_EXCEPTIONS[key]
          Contrast::TELEMETRY_EXCEPTIONS.increment(key)
          return
        end

        return if Contrast::TELEMETRY_EXCEPTIONS.exception_limit?

        message_exception_type = exception ? exception.cs__class.to_s : stack_frame_type.split('/').last
        event_message = create_message(stack_frame_function,
                                       stack_frame_type, message_exception_type,
                                       data, exception,
                                       message)
        build_stack(event_message, wrapped_caller_locations, caller_idx)
        TELEMETRY_EXCEPTIONS[key] = event_message
      rescue StandardError => e
        debug('[Telemetry] Unable to report exception', e)
      end

      # @return [Contrast::Agent::Telemetry::Exception::Event]
      def create_message stack_frame_function, stack_frame_type, message_exception_type, data, exception, message
        message_for_exception = if exception
                                  exception.cs__respond_to?(:message) ? exception.message : exception
                                else
                                  message
                                end
        module_name = message_exception_type ? message_exception_type.split('::')[0] : nil
        stack_frame = Contrast::Agent::Telemetry::Exception::StackFrame.build(stack_frame_function,
                                                                              stack_frame_type,
                                                                              module_name)
        message_exception = Contrast::Agent::Telemetry::Exception::MessageException.build(
            message_exception_type,
            message_for_exception,
            module_name,
            stack_frame)
        tags = if data
                 data
               else
                 exception.cs__is_a?(Hash) ? exception : {}
               end
        message = Contrast::Agent::Telemetry::Exception::Message.build(tags, [message_exception])
        Contrast::Agent::Telemetry::Exception::Event.new(message)
      rescue ArgumentError => e
        debug('[Telemetry] TelemetryException failed from aliased logging with: ', e)
      end

      # Convert the given caller_stack from the event into the exception StackFrame format for reporting, appending it
      # to the Exception wrapped in the Event.
      #
      # @param event_message [Contrast::Agent::Telemetry::Exception::Event]
      # @param caller_stack [(Array<Thread::Backtrace::Location>, nil]
      # @param caller_idx [Integer] the starting location for the exception, which allows filtering out the logger code
      #   from the stack
      def build_stack event_message, caller_stack, caller_idx = 0
        return unless caller_stack

        event_exception = event_message.exceptions[0]
        event_exception_message = event_exception.exceptions[0]

        caller_stack.each_with_index do |caller, idx|
          next unless idx > caller_idx

          obfuscated_label = Contrast::Agent::Telemetry::Exception::Obfuscate.obfuscate_path(caller.label)
          obfuscated_path =  Contrast::Agent::Telemetry::Exception::Obfuscate.
              obfuscate_path(caller.path.delete_prefix(Dir.pwd))

          stack_frame = Contrast::Agent::Telemetry::Exception::StackFrame.build(obfuscated_label, obfuscated_path)
          event_exception_message.push(stack_frame)
        end
      end

      # This is purely a wrapper around caller_locations used for testing
      #
      # @return [Array<Thread::Backtrace::Location>, nil]
      def wrapped_caller_locations
        caller_locations
      end

      # @param caller [Thread::Backtrace::Location, nil]
      # @return [String]
      def obfuscate_type caller
        return '' unless caller.cs__respond_to?(:path)

        Contrast::Agent::Telemetry::Exception::Obfuscate.obfuscate_path(caller.path.delete_prefix(Dir.pwd).to_s)
      end
    end
  end
end