# frozen_string_literal: true

require 'request_store'
require 'active_support'
require 'active_support/core_ext/module/delegation'
require 'active_support/core_ext/string/inflections'

require 'gitlab/experiment/errors'
require 'gitlab/experiment/base_interface'
require 'gitlab/experiment/cache'
require 'gitlab/experiment/callbacks'
require 'gitlab/experiment/rollout'
require 'gitlab/experiment/configuration'
require 'gitlab/experiment/cookies'
require 'gitlab/experiment/context'
require 'gitlab/experiment/dsl'
require 'gitlab/experiment/middleware'
require 'gitlab/experiment/nestable'
require 'gitlab/experiment/variant'
require 'gitlab/experiment/version'
require 'gitlab/experiment/engine' if defined?(Rails::Engine)

module Gitlab
  class Experiment
    include BaseInterface
    include Cache
    include Callbacks
    include Nestable

    class << self
      # Class level behavior registration methods.

      def control(*filter_list, **options, &block)
        variant(:control, *filter_list, **options, &block)
      end

      def candidate(*filter_list, **options, &block)
        variant(:candidate, *filter_list, **options, &block)
      end

      def variant(variant, *filter_list, **options, &block)
        build_behavior_callback(filter_list, variant, **options, &block)
      end

      # Class level callback registration methods.

      def exclude(*filter_list, **options, &block)
        build_exclude_callback(filter_list.unshift(block), **options)
      end

      def segment(*filter_list, variant:, **options, &block)
        build_segment_callback(filter_list.unshift(block), variant, **options)
      end

      def before_run(*filter_list, **options, &block)
        build_run_callback(filter_list.unshift(:before, block), **options)
      end

      def around_run(*filter_list, **options, &block)
        build_run_callback(filter_list.unshift(:around, block), **options)
      end

      def after_run(*filter_list, **options, &block)
        build_run_callback(filter_list.unshift(:after, block), **options)
      end

      # Class level definition methods.

      def default_rollout(rollout = nil, options = {})
        return @_rollout ||= Configuration.default_rollout if rollout.blank?

        @_rollout = Rollout.resolve(rollout, options)
      end

      # Class level accessor methods.

      def published_experiments
        RequestStore.store[:published_gitlab_experiments] || {}
      end
    end

    def name
      [Configuration.name_prefix, @_name].compact.join('_')
    end

    def control(&block)
      variant(:control, &block)
    end

    def candidate(name = nil, &block)
      if name.present?
        Configuration.deprecated(<<~MESSAGE, version: '0.7.0')
          passing name to `candidate` is deprecated and will be removed from {{release}} (instead use `variant(#{name.inspect})`)
        MESSAGE
      end

      variant(name || :candidate, &block)
    end

    def variant(name = nil, &block)
      if block.present? # we know we're defining a variant block
        raise ArgumentError, 'missing variant name' if name.blank?

        return behaviors[name.to_s] = block
      end

      if name.present?
        Configuration.deprecated(<<~MESSAGE, version: '0.7.0')
          setting the variant using `variant` is deprecated and will be removed from {{release}} (instead use `assigned(#{name.inspect})`)
        MESSAGE
      else
        Configuration.deprecated(<<~MESSAGE, version: '0.7.0')
          getting the assigned variant using `variant` is deprecated and will be removed from {{release}} (instead use `assigned`)
        MESSAGE
      end

      assigned(name)
    end

    def context(value = nil)
      return @_context if value.blank?

      @_context.value(value)
      @_context
    end

    def assigned(value = nil)
      @_assigned_variant_name = cache_variant(value) if value.present?
      if @_assigned_variant_name || @_resolving_variant
        return Variant.new(name: (@_assigned_variant_name || :unresolved).to_s)
      end

      if enabled?
        @_resolving_variant = true
        @_assigned_variant_name = cached_variant_resolver(@_assigned_variant_name)
      end

      run_callbacks(segmentation_callback_chain) do
        @_assigned_variant_name ||= :control
        Variant.new(name: @_assigned_variant_name.to_s)
      end
    ensure
      @_resolving_variant = false
    end

    def rollout(rollout = nil, options = {})
      return @_rollout ||= self.class.default_rollout(nil, options).for(self) if rollout.blank?

      @_rollout = Rollout.resolve(rollout, options).for(self)
    end

    def exclude!
      @_excluded = true
    end

    def run(variant_name = nil)
      return @_result if context.frozen?

      @_result = run_callbacks(run_callback_chain) { super(assigned(variant_name).name) }
    end

    def publish(result = nil)
      instance_exec(result, &Configuration.publishing_behavior)

      (RequestStore.store[:published_gitlab_experiments] ||= {})[name] = signature.merge(excluded: excluded?)
    end

    def track(action, **event_args)
      return unless should_track?

      instance_exec(action, tracking_context(event_args).try(:compact) || {}, &Configuration.tracking_behavior)
    end

    def process_redirect_url(url)
      return unless Configuration.redirect_url_validator&.call(url)

      track('visited', url: url)
      url # return the url, which allows for mutation
    end

    def enabled?
      rollout.enabled?
    end

    def excluded?
      return @_excluded if defined?(@_excluded)

      @_excluded = !run_callbacks(exclusion_callback_chain) { :not_excluded }
    end

    def should_track?
      enabled? && context.trackable? && !excluded?
    end

    def signature
      { variant: assigned.name, experiment: name }.merge(context.signature)
    end

    def key_for(source, seed = name)
      # TODO: Remove - deprecated in release 0.7.0
      if (block = Configuration.instance_variable_get(:@__context_hash_strategy))
        return instance_exec(source, seed, &block)
      end

      return source if source.is_a?(String)

      source = source.keys + source.values if source.is_a?(Hash)

      ingredients = Array(source).map { |v| identify(v) }
      ingredients.unshift(seed).unshift(Configuration.context_key_secret)

      Digest::SHA2.new(Configuration.context_key_bit_length).hexdigest(ingredients.join('|'))
    end

    protected

    def identify(object)
      (object.respond_to?(:to_global_id) ? object.to_global_id : object).to_s
    end

    def resolve_variant_name
      if respond_to?(:experiment_group?, true)
        # TODO: Remove - deprecated in release 0.7.0
        Configuration.deprecated(:experiment_group?, <<~MESSAGE, version: '0.7.0')
          instead put this logic into custom rollout strategies
        MESSAGE

        rollout.resolve if experiment_group?
      elsif (block = Configuration.instance_variable_get(:@__inclusion_resolver))
        # TODO: Remove - deprecated in release 0.7.0
        rollout.resolve if instance_exec(@_assigned_variant_name, &block)
      elsif (block = Configuration.instance_variable_get(:@__variant_resolver))
        # TODO: Remove - deprecated in release 0.6.5
        instance_exec(@_assigned_variant_name, &block)
      else
        rollout.resolve # this is the end result of all deprecations
      end
    end

    def tracking_context(event_args)
      {}.merge(event_args)
    end
  end
end
