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

require 'contrast/utils/assess/tracking_util'
require 'contrast/utils/class_util'
require 'contrast/utils/duck_utils'
require 'contrast/utils/object_share'
require 'contrast/utils/stack_trace_utils'
require 'contrast/utils/string_utils'
require 'contrast/utils/timer'
require 'contrast/agent/assess/contrast_object'

module Contrast
  module Agent
    module Assess
      # This class holds the data about an event in the application We'll use it to build an event that TeamServer can
      # consume if the object to which this event belongs ends in a trigger.
      #
      # @attr_reader event_id [Integer] the atomic id of this event
      # @attr_reader policy_node [Contrast::Agent::Assess::Policy::PolicyNode] the node that governs this event.
      # @attr_reader stack_trace [Array<String>] the execution stack at the time the method for this event was invoked
      # @attr_reader time [Integer] the time, in epoch ms, when this event was created
      # @attr_reader thread [Integer] the object id of the thread on which this event was generated
      # @attr_reader object [Contrast::Agent::Assess::ContrastObject] the safe representation of the Object on which
      #   the method was invoked
      # @attr_reader ret [Contrast::Agent::Assess::ContrastObject] the safe representation of the Return of the invoked
      #   method
      # @attr_reader args [Array<Contrast::Agent::Assess::ContrastObject>] the safe representation of the Arguments
      #   with which the method was invoked
      class ContrastEvent
        attr_reader :event_id, :policy_node, :stack_trace, :time, :thread, :object, :ret, :args, :tags

        # We need this to track the parent id's of events to build up a flow chart of the finding
        @atomic_id = 0
        @atomic_mutex = Mutex.new
        def self.next_atomic_id
          @atomic_mutex.synchronize do
            @atomic_id += 1
            # Rollover things
          rescue StandardError
            @atomic_id = 1
          end
        end

        # @param policy_node [Contrast::Agent::Assess::Policy::PolicyNode] the node that governs this event.
        # @param tagged [Object] the Target to which this event pertains.
        # @param object [Object] the Object on which the method was invoked
        # @param ret [Object] the Return of the invoked method
        # @param args [Array<Object>] the Arguments with which the method was invoked
        def initialize policy_node, tagged, object, ret, args
          @policy_node = policy_node
          @time = Contrast::Utils::Timer.now_ms
          @thread = Thread.current.object_id

          # These methods rely on the above being set. Don't move them!
          @event_id = Contrast::Agent::Assess::ContrastEvent.next_atomic_id
          @tags = Contrast::Agent::Assess::Tracker.properties(tagged)&.get_tags
          find_parent_events!(policy_node, object, ret, args)
          snapshot!(object, ret, args)
          capture_stacktrace!
        end

        def parent_events
          @_parent_events ||= []
        end

        # We have to do a little work to figure out what our TS appropriate target is. To break this down, the logic is
        # as follows:
        # 1) If my policy_node has a target, work on targets. Else, work on sources. Per TS law, each policy_node must
        #    have at least a source or a target. The only type of policy_node w/o targets is a Trigger, but that may
        #    change.
        # 2) I'll set the event's source and target to TS values.
        # 3) Return the first source/target as the taint target.
        def determine_taint_target event_dtm
          if @policy_node&.targets&.any?
            event_dtm.source = @policy_node.source_string if @policy_node.source_string
            event_dtm.target = @policy_node.target_string
            @policy_node.targets[0]
          elsif policy_node&.sources&.any?
            event_dtm.source = @policy_node.source_string
            event_dtm.target = @policy_node.target_string if @policy_node.target_string
            @policy_node.sources[0]
          end
        end

        # Convert this event into a DTM that TeamServer can consume
        def to_dtm_event
          Contrast::Api::Dtm::TraceEvent.build(self)
        end

        private

        # Parent events are the events of all the sources of this event which were tracked prior to this event
        # occurring. Depending on which, if any of the sources were tracked, there may be more than one parent.
        #
        # All events except for [Contrast::Agent::Assess::Events::SourceEvent] will have at least one parent.
        #
        # We set those events to this event's instance variables.
        #
        # @param policy_node [Contrast::Agent::Assess::Policy::PolicyNode] the node that governs this event.
        # @param object [Object] the Object on which the method was invoked
        # @param ret [Object] the Return of the invoked method
        # @param args [Array<Object>] the Arguments with which the method was invoked
        def find_parent_events! policy_node, object, ret, args
          policy_node.sources.each do |source_marker|
            source = value_of_source(source_marker, object, ret, args)
            next unless source

            event = Contrast::Agent::Assess::Tracker.properties(source)&.event
            parent_events << event if event
          end
        end

        # @param source [String] the marker for the source type
        # @param object [Object] the Object on which the method was invoked
        # @param ret [Object] the Return of the invoked method
        # @param args [Array<Object>] the Arguments with which the method was invoked
        # @return [Object,nil] the literal value of the source indicated by the given marker
        def value_of_source source, object, ret, args
          case source
          when Contrast::Utils::ObjectShare::OBJECT_KEY
            object
          when Contrast::Utils::ObjectShare::RETURN_KEY
            ret
          else
            args[source]
          end
        end

        # Everything* is mutable in Ruby. As such, to ensure we can accurately report the application state at the time
        # of this method's invocation, we have to snapshot the given values, making safe representations of them for
        # our later use. We set those safe values to this event's instance variables.
        #
        # @param object [Object] the Object on which the method was invoked
        # @param ret [Object] the Return of the invoked method
        # @param args [Array<Object>] the Arguments with which the method was invoked
        def snapshot! object, ret, args
          @object = Contrast::Agent::Assess::ContrastObject.new(object) if object
          @ret = Contrast::Agent::Assess::ContrastObject.new(ret) if ret
          @args = safe_args_representation(args)
          self
        end

        # Given an array of arguments, copy them into a safe, meaning String, format that we can use to send to SR and
        # TS for rendering.
        #
        # @param args [Array<Object>] the arguments to translate
        # @return [Array<Contrast::Agent::Assess::ContrastObject>] the String forms of those Objects, as determined by
        #   Contrast::Utils::ClassUtil.to_contrast_string
        def safe_args_representation args
          return unless args
          return Contrast::Utils::ObjectShare::EMPTY_ARRAY if args.empty?

          args.map { |arg| arg ? Contrast::Agent::Assess::ContrastObject.new(arg) : nil }
        end

        # Capture stack traces only as configured. We'll use this to grab the start of the call stack as if the
        # instrumented method were the caller. This means we'll start at the entry just after the first block of
        # Contrast code.
        def capture_stacktrace!
          # If we're configured to not capture the stacktrace, usually for performance reasons, then don't and return an
          # empty array instead
          unless ::Contrast::ASSESS.capture_stacktrace?(policy_node)
            @stack_trace = Contrast::Utils::ObjectShare::EMPTY_ARRAY
            return
          end

          # Otherwise, find where in the stack the application / Ruby code starts
          start = caller(0, 20)&.find_index { |stack| !stack.include?('/lib/contrast') }
          # And then use that to build out the reported stacktrace, or a fallback if we couldn't find it.
          @stack_trace = start ? caller(start + 1, 20) : caller(20, 20)
        end
      end
    end
  end
end