# Copyright 2024 - 2025 Block, Inc.
#
# Use of this source code is governed by an MIT-style
# license that can be found in the LICENSE file or at
# https://opensource.org/licenses/MIT.
#
# frozen_string_literal: true

# Put the lib dir of `spec_support` on the load path so we can load things from it.
$LOAD_PATH.unshift("#{__dir__}/lib")

# For simplecov to measure coverage of all files, it MUST be loaded and started before we load any of our
# files (so this must be the first thing here!). It makes our tests a bit slower, so it's setup as an
# "opt-in" feature of our test suite. To enable it, run the test suite with the `COVERAGE` ENV var set, e.g.:
#
# ```
# $ COVERAGE=1 bundle exec rspec path/to/gem/spec
# ```
require "elastic_graph/spec_support/enable_simplecov" if ENV["COVERAGE"]

# super_diff generally provides much better diffs than RSpec provides on its own. However, sometimes it makes the failure output worse,
# and we've also run into bugs with it. We've been impacted by all of these issues:
#
# https://github.com/mcmire/super_diff/issues/160
# https://github.com/mcmire/super_diff/issues/250
# https://github.com/mcmire/super_diff/issues/252
# https://github.com/mcmire/super_diff/issues/253
#
# So for now we keep super_diff disabled by default. But it's available to opt into by passing `SUPERDIFF=1` at the command line.
# This can be useful when dealing with a spec with hard-to-parse failure output--try it with `SUPERDIFF=1` and see if it helps.
#
# :nocov: -- only one side of this conditional gets executed on any given test run.
if ENV["SUPERDIFF"]
  require "super_diff/rspec"

  SuperDiff.configure do |config|
    config.actual_color = :green
    config.expected_color = :red
    config.border_color = :yellow
    config.header_color = :yellow
  end
end
# :nocov:

# This file was generated by the `rspec --init` command. Conventionally, all
# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
# The generated `.rspec` file contains `--require spec_helper` which will cause
# this file to always be loaded, without a need to explicitly require it in any
# files.
#
# Given that it is always loaded, you are encouraged to keep this file as
# light-weight as possible. Requiring heavyweight dependencies from this file
# will add to the boot time of your test suite on EVERY test run, even for an
# individual file that may not need all of that loaded. Instead, consider making
# a separate helper file that requires the additional dependencies and performs
# the additional setup, and require it from the spec files that actually need
# it.
#
# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
RSpec.configure do |config|
  # We don't want to skip the majority of the tests due to misuse of `fit`
  if ENV["CI"]
    config.before(:example, :focus) do
      abort "Focusing examples in a CI environment is not allowed. Was `fit`, `fdescribe`, or `fcontext` left somewhere?"
    end
  end

  # We intentionally use `abort` in some places, but do not want the RSpec runner to abort, so here we rescue that
  # exception and fail with a clear error indicating `abort_with` should be used.
  config.around(:example) do |ex|
    ex.run
  rescue ::SystemExit => e
    # :nocov: -- we only get here when we forget to use `abort_with`, so this is not covered on a normal, passing test suite run.
    fail <<~EOS
      The example attempted to exit with the following `SystemExit` error. If the implementation uses `abort` to cleanly notify the user of an issue, use the `abort_with` matcher to specify it in the spec.
      #{"-" * 120}

      #{e.message}

      #{e.backtrace.join("\n")}"
    EOS
    # :nocov:
  end

  # rspec-expectations config goes here. You can use an alternate
  # assertion/expectation library such as wrong or the stdlib/minitest
  # assertions if you prefer.
  config.expect_with :rspec do |expectations|
    # This option will default to `true` in RSpec 4. It makes the `description`
    # and `failure_message` of custom matchers include text for helper methods
    # defined using `chain`, e.g.:
    #     be_bigger_than(2).and_smaller_than(4).description
    #     # => "be bigger than 2 and smaller than 4"
    # ...rather than:
    #     # => "be bigger than 2"
    expectations.include_chain_clauses_in_custom_matcher_descriptions = true

    # RSpec truncates the output when it formats our arbitrary objects in its
    # failure messages to ensure there isn't a giant wall of text. It truncates
    # it at a couple hundered characters, which is shorter than we'd like and
    # omits useful detail so here we make it larger.
    expectations.max_formatted_output_length = 10_000
  end

  # rspec-mocks config goes here. You can use an alternate test double
  # library (such as bogus or mocha) by changing the `mock_with` option here.
  config.mock_with :rspec do |mocks|
    # Prevents you from mocking or stubbing a method that does not exist on
    # a real object. This is generally recommended, and will default to
    # `true` in RSpec 4.
    mocks.verify_partial_doubles = true
  end

  %i[unit integration acceptance].each do |type|
    config.define_derived_metadata(file_path: %r{/spec/#{type}/}) do |meta|
      meta[:type] ||= type
    end
  end

  # This turns on the aggregate_failures feature of RSpec 3.3 by default.
  #
  # Within the aggregate_failures block, expectations failures do not cause
  # the example to abort. Instead, a single aggregate exception will be raised at
  # the end containing multiple sub-failures which RSpec will format nicely for you.
  #
  # The unless meta.key?(:aggregate_failures) bit allows you to opt out individual
  # examples or groups by tagging them with aggregate_failures: false.
  #
  # See more here: http://rspec.info/blog/2015/06/rspec-3-3-has-been-released/
  config.define_derived_metadata do |meta|
    # We generally want to set `aggregate_failures: true` for nicer RSpec output when
    # an example has multiple expectations. However, we do not want to set it to `true`
    # in a few cases:
    #
    # - When the example has explicitly set `aggregate_failures: false`.
    # - When dealing with an example that accesses the datastore and uses and `raise_error`.
    #   We use rspec-retry to auto-retry examples that fail due to VCR::Errors::UnhandledHTTPRequestError.
    #   VCR is always enabled for examples that set `:uses_datastore`. If a VCR error is raised in a
    #   `raise_error` block, it gets caught and RSpec notifies of the failed expectation. When
    #   `aggregate_failures` is set to `true`, RSpec notifies failures at the end of the example, after
    #   rspec-retry checks the example exception to see if it should retry. To avoid problems here, we need
    #   to set `aggregate_failures` to `false` in that case.
    unless meta.key?(:aggregate_failures)
      # example groups have a `:parent_example_group` key instead of `:example_group`
      # And we only want to set `aggregate_failures` on examples.
      if meta.key?(:example_group)
        meta[:aggregate_failures] = !defined?(::VCRSupport) || ::VCRSupport.aggregate_failures_meta_value_for(meta)
      end
    end
  end

  # This option will default to `:apply_to_host_groups` in RSpec 4 (and will
  # have no way to turn it off -- the option exists only for backwards
  # compatibility in RSpec 3). It causes shared context metadata to be
  # inherited by the metadata hash of host groups and examples, rather than
  # triggering implicit auto-inclusion in groups with matching metadata.
  config.shared_context_metadata_behavior = :apply_to_host_groups

  # This allows you to limit a spec run to individual examples or groups
  # you care about by tagging them with `:focus` metadata. When nothing
  # is tagged with `:focus`, all examples get run. RSpec also provides
  # aliases for `it`, `describe`, and `context` that include `:focus`
  # metadata: `fit`, `fdescribe` and `fcontext`, respectively.
  config.filter_run_when_matching :focus

  # Allows RSpec to persist some state between runs in order to support
  # the `--only-failures` and `--next-failure` CLI options. We recommend
  # you configure your source control system to ignore this file.
  config.example_status_persistence_file_path = "#{__dir__}/../tmp/rspec/stats.txt"

  # Limits the available syntax to the non-monkey patched syntax that is
  # recommended. For more details, see:
  #   - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/
  #   - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/
  #   - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode
  config.disable_monkey_patching!

  # Many RSpec users commonly either run the entire suite or an individual
  # file, and it's useful to allow more verbose output when running an
  # individual spec file.
  # Use the documentation formatter for detailed output when running one
  # file, or the progress formatter when running many files. The user can
  # still pick a formatter directly via command-line flag.
  config.default_formatter = config.files_to_run.one? ? "doc" : "progress"

  # Print the slowest examples and example groups at the
  # end of the spec run, to help surface which specs are running
  # particularly slow. If we are running only one file, cut the
  # number we print to 3.
  config.profile_examples = config.files_to_run.one? ? 3 : 10

  # Run specs in random order to surface order dependencies. If you find an
  # order dependency and want to debug it, you can fix the order by providing
  # the seed, which is printed after each run.
  #     --seed 1234
  config.order = :random

  # Seed global randomization in this process using the `--seed` CLI option.
  # Setting this allows you to use `--seed` to deterministically reproduce
  # test failures related to randomization by passing the same `--seed` value
  # as the one that triggered the failure.
  Kernel.srand config.seed

  config.raise_on_warning = true
  config.raise_errors_for_deprecations!

  config.when_first_matching_example_defined(:uses_datastore) { require "elastic_graph/spec_support/uses_datastore" }
  config.when_first_matching_example_defined(:builds_admin) { require "elastic_graph/spec_support/builds_admin" }
  config.when_first_matching_example_defined(:builds_datastore_core) { require "elastic_graph/spec_support/builds_datastore_core" }
  config.when_first_matching_example_defined(:builds_indexer) { require "elastic_graph/spec_support/builds_indexer" }
  config.when_first_matching_example_defined(:builds_graphql) { require "elastic_graph/spec_support/builds_graphql" }
  config.when_first_matching_example_defined(:capture_logs) { require "elastic_graph/spec_support/logging" }
  config.when_first_matching_example_defined(:factories) { require "elastic_graph/spec_support/factories" }
  config.when_first_matching_example_defined(:rack_app) { require "elastic_graph/spec_support/rack_app" }
  config.when_first_matching_example_defined(:rake_task) { require "elastic_graph/spec_support/rake_task" }
  config.when_first_matching_example_defined(:stub_datastore_client) { require "elastic_graph/spec_support/stub_datastore_client" }
  config.when_first_matching_example_defined(:vcr) { require "elastic_graph/spec_support/vcr" }

  config.when_first_matching_example_defined(:in_temp_dir) { require "tmpdir" }
  config.around(:example, :in_temp_dir) do |ex|
    Dir.mktmpdir { |tmp_dir| Dir.chdir(tmp_dir, &ex) }
  end

  # Here we define support for `:no_vcr` which can be used to tag groups or examples where we don't
  # want VCR to be used for some reason. `no_vcr: false` is added by default as metadata to every
  # example (so that we can filter on `:vcr, no_vcr: false` in `spec_support/vcr.rb`), but when
  # an example is tagged with `:no_vcr` we disable VCR while the test runs.
  config.around(:example, :no_vcr) { |ex| without_vcr(&ex) }
  config.define_derived_metadata do |meta|
    meta[:no_vcr] = false unless meta.key?(:no_vcr)
  end

  config.when_first_matching_example_defined(type: :acceptance) do
    config.mock_with :rspec do |mocks|
      # Guard against typos in string constant names used with verifying doubles
      # when we have loaded the entire application (as indicated by the fact that
      # an acceptance spec has been loaded).
      mocks.verify_doubled_constant_names = true
    end
  end
end

RSpec::Matchers.define_negated_matcher :exclude, :include
RSpec::Matchers.define_negated_matcher :excluding, :including
RSpec::Matchers.define_negated_matcher :a_hash_excluding, :a_hash_including
RSpec::Matchers.define_negated_matcher :a_string_excluding, :a_string_including
RSpec::Matchers.define_negated_matcher :a_collection_excluding, :a_collection_including
RSpec::Matchers.define_negated_matcher :maintain, :change
RSpec::Matchers.define_negated_matcher :raise_no_error, :raise_error
RSpec::Matchers.define_negated_matcher :avoid_outputting, :output
RSpec::Matchers.define_negated_matcher :have_never_received, :have_received

module ElasticGraph
  module CommonSpecHelpers
    REPO_ROOT = File.expand_path("../..", __FILE__)
    TEST_SETTINGS_FILE_TEMPLATE = File.join(REPO_ROOT, "config", "settings", "test.yaml.template")

    def self.datastore_backend=(backend)
      # :nocov: -- the `raise` is only hit when the method is called more than once, which should never happen.
      raise "Cannot set `datastore_backend` more than once, but it has already been set." if @test_settings_file
      # :nocov:

      require "tempfile"
      @test_settings_file = ::Tempfile.new("test.yaml")
      template = ::File.read(TEST_SETTINGS_FILE_TEMPLATE)
      ::File.write(@test_settings_file, template % {datastore_backend: backend.to_s})

      puts "\n\n**** Using the #{backend} datastore client for this test run."
    end

    # The test settings file path is always the same. However, the contents of that file must be different when we are running the
    # test suite against OpenSearch vs Elasticsearch, and `datastore_backend=` generates the test settings file with appropriately
    # different contents in tbhose two cases. In cases where the specific backend used really matters, `datastore_backend=` gets
    # eagerly called to force which one is used. Otherwise, we lazily pick one of the two backends here. In that case, which we use
    # shouldn't matter. To ensure we don't assume one or the other, we randomly pick one of the two backends.
    #
    # We've made `test_settings_file` a method instead of a constant so that we can lazily generate the test settings file when needed.
    def self.test_settings_file
      self.datastore_backend = [:elasticsearch, :opensearch].sample unless @test_settings_file
      @test_settings_file
    end

    def self.stock_schema_artifacts
      @stock_schema_artifacts ||= begin
        require "elastic_graph/schema_artifacts"
        SchemaArtifacts.from_parsed_yaml(parsed_test_settings_yaml)
      end
    end

    def self.parsed_test_settings_yaml
      @parsed_test_settings_yaml ||= begin
        require "yaml"
        parsed_yaml = ::YAML.safe_load_file(test_settings_file, aliases: true)
        schema_artifacts = parsed_yaml.fetch("schema_artifacts")
        schema_artifacts_directory = schema_artifacts.fetch("directory")

        # To allow the spec suite of each gem to pass when run from either the repository root or
        # from the gem directory, we need to replace the schema artifacts directory relative path
        # with an absolute path.
        parsed_yaml.merge(
          "schema_artifacts" => schema_artifacts.merge(
            "directory" => schema_artifacts_directory.sub("config", File.join(REPO_ROOT, "config"))
          )
        )
      end
    end

    # Like `raise_error` matcher but for use when the implementation code uses `abort` instead of `raise`. Note:
    #
    # - `abort` prints the exception message to `stderr` in addition to including it in the raised `SystemExit` exception.
    #   We don't want it printed in our spec output so we silence that by using the `output` matcher.
    # - Care must be taken when passing a block to this method, and curly braces should be used.
    #   If you do `expect { }.to abort_with do |error|...`, the block binds to the `to` method rather than
    #   `abort_with` and the block is silently ignored. When curly braces are used instead, the block binds to
    #   the `abort_with` method and works as expected.
    def abort_with(*args, &block)
      raise_error(::SystemExit, *args, &block).and output(/./).to_stderr
    end

    def stock_schema_artifacts
      CommonSpecHelpers.stock_schema_artifacts
    end

    def parsed_test_settings_yaml
      CommonSpecHelpers.parsed_test_settings_yaml
    end

    # Ruby 3.4 improved the `#inspect` output of a `Hash`:
    # - `{foo: 1}` inspects as `{foo: 1}` instead of `{:foo=>1}`
    # - `{"foo" => 1}` inspects as `{"foo" => 1}` instead of `{"foo"=>1}`
    #
    # Some of our specs expect `#inspect` output or an error message generated from `#inspect` output.
    # We've updated those expectations to match the new Ruby 3.4 formatting. To pass against older
    # Rubies, `#inspect_output_of` downgrades the output to match the old output seen on Ruby 3.3 and before.
    # :nocov: -- only one side of this branch is covered on a given test suite run (based on the Ruby version)
    if RUBY_VERSION >= "3.4"
      def inspect_output_of(output) = output
    else
      def inspect_output_of(output)
        output
          .gsub(/(\S) =>/, '\1=>')
          .gsub(/=> (\S)/, '=>\1')
          .gsub(/(\w+): /, ':\1=>')
      end
    end
    # :nocov:

    module_function

    def expect_to_return_non_nil_values_from_all_attributes(object)
      exposed_attributes = object
        .public_methods(false)
        .select { |m| object.method(m).arity.zero? }

      exposed_attributes.each do |attr|
        expect(object.public_send(attr)).not_to be_nil, "Expected `#{object}.#{attr}` not to be nil but was"
      end
    end

    def a_boolean
      an_object_eq_to(true).or an_object_eq_to(false)
    end

    # :nocov: -- any given run either runs with VCR loaded or w/o it loaded, and won't cover all branches here.
    def without_vcr
      return yield unless defined?(::VCR) # since we support running w/o VCR.
      VCR.eject_cassette
      VCR.turned_off { yield }
    end
    # :nocov:

    def generate_schema_artifacts(
      schema_element_name_form: :snake_case,
      schema_element_name_overrides: {},
      derived_type_name_formats: {},
      enum_value_overrides_by_type: {},
      reload_schema_artifacts: false
    )
      require "elastic_graph/schema_definition/test_support"
      require "stringio"

      output = ::StringIO.new # to silence warnings.
      ::ElasticGraph::SchemaDefinition::TestSupport.define_schema(
        schema_element_name_form: schema_element_name_form,
        schema_element_name_overrides: schema_element_name_overrides,
        derived_type_name_formats: derived_type_name_formats,
        enum_value_overrides_by_type: enum_value_overrides_by_type,
        reload_schema_artifacts: reload_schema_artifacts,
        output: output
      ) do |schema|
        if block_given?
          yield schema
        else
          schema.as_active_instance { load File.join(REPO_ROOT, "config", "schema.rb") }
        end
      end
    end

    # Helper method to assist in building a `Config::IndexDefinition`. Provides defaults so that
    # a test can just specify the details that matter to it.
    def config_index_def_of(
      query_cluster: "main",
      index_into_clusters: ["main"],
      ignore_routing_values: [],
      setting_overrides: {},
      setting_overrides_by_timestamp: {},
      custom_timestamp_ranges: []
    )
      require "elastic_graph/datastore_core/configuration/index_definition"
      DatastoreCore::Configuration::IndexDefinition.from(
        query_cluster: query_cluster,
        index_into_clusters: index_into_clusters,
        ignore_routing_values: ignore_routing_values,
        setting_overrides: setting_overrides,
        setting_overrides_by_timestamp: setting_overrides_by_timestamp,
        custom_timestamp_ranges: custom_timestamp_ranges
      )
    end

    def cluster_of(url: "https://some/url", backend: nil, settings: {})
      backend ||= respond_to?(:datastore_backend) ? datastore_backend : :elasticsearch

      require "elastic_graph/datastore_core/configuration/cluster_definition"
      DatastoreCore::Configuration::ClusterDefinition.from_hash({
        "url" => url,
        "backend" => backend.to_s,
        "settings" => settings
      })
    end

    def with_env(overrides)
      env = ENV.to_h
      ENV.update(overrides)

      begin
        yield
      ensure
        ENV.replace(env)
      end
    end
  end
end

RSpec.configure do |c|
  c.define_derived_metadata(type: :unit) { |m| m[:stub_datastore_client] = true unless m.key?(:stub_datastore_client) }
  c.include ElasticGraph::CommonSpecHelpers
end

# If we're using the parallel test runner, we have to load our adapters which patch various bits of behavior
# to make it safe to run tests in parallel.
require "elastic_graph/spec_support/parallel_spec_runner" if defined?(::Flatware)
require "elastic_graph/spec_support/validate_graphql_schemas"
require "pathname"

# Identify the gem directories from which we are running specs...
gem_dirs = RSpec.configuration
  .files_to_run
  .filter_map { |f| Pathname(f).ascend.find { |p| p.glob("*.gemspec").any? } }
  .map(&:expand_path)
  .uniq

# ...and then load the `spec_helper` file for each, and put `spec` on the load path.
gem_dirs.each do |gem_dir|
  $LOAD_PATH.unshift((gem_dir / "spec").to_s)
  require((gem_dir / "spec" / "spec_helper.rb").to_s)
end
