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

require 'contrast/utils/duck_utils'
require 'contrast/agent/assess/policy/propagation_method'

module Contrast
  module Agent
    module Assess
      module Finalizers
        # An extension of Hash that doesn't impact GC of the object being stored by storing its ID as a Key to lookup
        # and registering a finalizer on the object to remove its entry from the Hash immediately after it's GC'd.
        class Hash < Hash
          FROZEN_FINALIZED_IDS = Set.new

          def []= key, obj
            return unless obj
            return unless ::Contrast::AGENT.enabled? && ::Contrast::ASSESS.enabled?

            # We can't finalize frozen things, so only act on those that went through .pre_freeze
            if key.cs__frozen?
              return unless FROZEN_FINALIZED_IDS.include?(key.__id__)
            else
              ObjectSpace.define_finalizer(key, finalizing_proc)
            end
            super(key.__id__, obj)
          end

          def [] key
            super(key.__id__)
          end

          # Something is trackable if it is not a collection and either not frozen or it was frozen after we put a
          # finalizer on it.
          #
          # @param key [Object] the thing to determine if trackable
          # @return [Boolean]
          def trackable? key
            return false unless key
            # Track things in these, not them themselves.
            return false if Contrast::Utils::DuckUtils.iterable_hash?(key)
            return false if Contrast::Utils::DuckUtils.iterable_enumerable?(key)
            # If it's not frozen, we can finalize/ track it.
            return true unless key.cs__frozen?

            # Otherwise, we can only track it if we've finalized it in our freeze patch.
            FROZEN_FINALIZED_IDS.include?(key.__id__)
          end

          # Determine if the given Object is tracked, meaning it has a known set of properties and those properties are
          # tracked.
          #
          # @param key [Object] the Object whose properties, by id, we want to check for tracked status
          # @return [Boolean]
          def tracked? key
            key?(key.__id__) && fetch(key.__id__, nil)&.tracked?
          end

          # Create a Proc to remove the given key from our frozen and properties tracking during finalization of the
          # Object to which the given key_id pertains. The ObjectSpace's finalizer mechanism will handle passing this
          # ID in, so we only need to define the Proc once.
          #
          # NOTE: by necessity, this is the only method which takes the __id__, not the Object itself. You CANNOT pass
          # the Object to this as a finalizer cannot hold reference to the Object being finalized; that prevents GC,
          # which introduces a memory leak and defeats the entire purpose of this.
          #
          # @return [Proc] the Proc to remove references to the ID of the GC'd object from our Hash
          def finalizing_proc
            @_finalizing_proc ||= proc do |key_id|
              FROZEN_FINALIZED_IDS.delete(key_id)
              Contrast::Agent::Assess::Policy::PropagationMethod.instance_variable_get(:@properties).delete(key_id)
              delete(key_id)
            end
          end

          # Frozen things cannot be finalized. To avoid any issue here, we intercept the #freeze call and set
          # finalizers on the Object. To ensure later we know it's been pre-finalized, we add it's __id__ to our
          # tracking.
          #
          # @param key [Object] the Object on which we need to pre-define finalizers
          def pre_freeze key
            return unless ::Contrast::AGENT.enabled? && ::Contrast::ASSESS.enabled?
            return if key.cs__frozen?
            return if FROZEN_FINALIZED_IDS.include?(key.__id__)

            ObjectSpace.define_finalizer(key, finalizing_proc)

            FROZEN_FINALIZED_IDS << key.__id__
          rescue StandardError => _e
            nil
          end
        end
      end
    end
  end
end