# frozen_string_literal: true

if ENV['CI'] && RUBY_VERSION.start_with?('3.4') # match version in tests.yml
  require 'simplecov'
  require 'simplecov-cobertura'
  SimpleCov.formatter = SimpleCov::Formatter::CoberturaFormatter
  SimpleCov.start
end

require 'js_regex'

include JsRegex::Target

RSpec.configure do |config|
  config.mock_with(:rspec) { |mocks| mocks.verify_partial_doubles = true }
end

# matcher to check for regexp resulting from conversion
RSpec::Matchers.define(:become) do |expected|
  chain(:with_warning) { |warning = true| @expected_warning = warning }
  chain(:with_options) { |options| @expected_options = options }

  match do |rb_regex|
    conversions(rb_regex).all? do |target, js_regex|
      @target = target
      expected_source = expected.is_a?(Regexp) ? expected.source : expected
      @msg = error_for_source(js_regex, expected_source) ||
               error_for_warnings(js_regex, @expected_warning) ||
               error_for_options(js_regex, @expected_options)
      @msg.nil?
    end
  end

  failure_message { @msg + " for #{@target}" }
end

# Some conversions are tested very often, caching speeds up the specs a bit.
$js_regex_cache = {}

# Returns a Hash of all targetted ECMAScript versions, mapped to their
# JsRegex output. Use `targets:` RSpec metadata to test specific version(s).
# This method should be used for all JsRegex initializations in syntax specs.
def conversions(rb_regex)
  active_targets.to_h do |target|
    output = $js_regex_cache[[rb_regex, target]] ||=
      JsRegex.new(rb_regex, target: target)
    [target, output]
  end
end

def active_targets
  Thread.current[:js_regex_custom_targets] || JsRegex::Target::SUPPORTED
end

def use_custom_targets(arg)
  raise 'choose at least one target' if arg && arg.empty?
  Thread.current[:js_regex_custom_targets] = arg.nil? ? nil : Array(arg)
end

# Support setting targets via example metadata.
RSpec.configure do |config|
  config.around do |example|
    use_custom_targets(example.metadata[:targets])
    example.call
  end
end

def error_for_source(js_regex, expected)
  if js_regex.source != expected
    "expected #{expected}, got #{js_regex.source}"
  elsif !to_s_like_json(js_regex)
    '#to_s/#to_json sanity check failed'
  end
end

def error_for_warnings(js_regex, expected)
  warnings = js_regex.warnings
  if !expected
    "expected no warnings, got #{warnings}" if warnings.any?
  elsif expected == true
    "expected warning(s), got none" if warnings.empty?
  else
    expected_array = Array(expected)
    if warnings.count != expected_array.count
      "expected #{expected_array.count} warning(s), got #{warnings.count}"
    else
      expected_array.each do |exp_warning|
        case exp_warning
        when Regexp
          next if warnings.any? { |w| w.match(exp_warning) }
        when String
          next if warnings.any? { |w| w.include?(exp_warning) }
        else
          raise "unsupported expected warning class: #{exp_warning.class}"
        end
        return "no warning matches #{exp_warning.inspect}"
      end
      nil
    end
  end
end

def error_for_options(js_regex, expected)
  if expected && js_regex.options != expected
    "expected options #{expected.inspect}, got #{js_regex.options.inspect}"
  end
end

# matcher to check for conversions that return an unchanged regex
RSpec::Matchers.define(:stay_the_same) do
  chain(:with_warning) { |warning = true| @expected_warning = warning }

  match do |rb_regex|
    conversions(rb_regex).all? do |target, js_regex|
      @target = target
      @msg = error_for_source(js_regex, rb_regex.source) ||
               error_for_warnings(js_regex, @expected_warning)
      @msg.nil?
    end
  end

  failure_message { @msg + " for #{@target}" }
end

# matcher to check for warnings generated by a conversion
RSpec::Matchers.define(:generate_warning) do |expected = true|
  match do |rb_regex|
    conversions(rb_regex).all? do |target, js_regex|
      @target = target
      @msg = error_for_warnings(js_regex, expected)
      @msg.nil?
    end
  end

  failure_message { @msg + " for #{@target}" }
end

# Sanity test. Checks that matching results in Ruby (using the original Regexp)
# and in JS (using the conversion result) are identical.
RSpec::Matchers.define(:keep_matching) do |*test_strings, with_results: nil|
  match do |rb_regex|
    conversions(rb_regex).all? do |target, js_regex|
      @target = target
      test_strings.each do |string|
        if with_results
          rb_matches = string.scan(rb_regex).flatten
          js_matches = matches_in_js(js_regex, string)
          rb_matches == with_results || @msg = "rb matched #{rb_matches}"
          js_matches == with_results || @msg = "js matched #{js_matches}"
        else
          # Due to JS' different splitting of group match data, some return values
          # are not completely identical between Ruby and JS matching calls.
          # In that case, don't specify expected results and just check that
          # a valid string does produce a match.
          rb_regex =~ string           || @msg = "rb did not match `#{string}`"
          test_in_js(js_regex, string) || @msg = "js did not match `#{string}`"
        end
      end

      @msg.nil?
    end
  end

  failure_message { @msg + " for #{@target}" }
end

RSpec::Matchers.define(:keep_not_matching) do |*test_strings|
  match do |rb_regex|
    @errors = []
    conversions(rb_regex).all? do |target, js_regex|
      @target = target
      test_strings.each do |string|
        rb_regex =~ string           && @errors.push("rb did match `#{string}`")
        test_in_js(js_regex, string) && @errors.push("js did match `#{string}`")
      end
      @errors.empty?
    end
  end

  failure_message { "#{@errors.join(', ')} for #{@target}" }
end

# match on [regexp_parser_token_class, regexp_parser_token_token]
RSpec::Matchers.define(:be_dropped_with_warning) do
  match do |(token_class, subtype)|
    exp = expression_double(type: token_class, token: subtype)
    allow_any_instance_of(Regexp::Expression::Root).to receive(:map).and_yield(exp)
    active_targets.all? do |target|
      @target = target
      result = JsRegex.new(/dummy/, target: target)
      source = result.source
      source.empty? || @msg = "expected empty source, got `#{source}`"
      result.warnings.count > 0 || @msg = 'did not warn'

      @msg.nil?
    end
  end

  failure_message { @msg + " for #{@target}" }
end

def expression_double(attributes)
  defaults = { case_insensitive?: false, map: [].map,
               quantifier: nil, to_s: 'X', ts: 0, i?: false }
  instance_double(Regexp::Expression::Root, defaults.merge(attributes))
end

require 'mini_racer'
JS_CONTEXT = MiniRacer::Context.new

def eval_js(string)
  JS_CONTEXT.eval(string)
end

def matches_in_js(js_regex, string)
  # the g-option is appended to get multiple matches at once
  eval_js("'#{js_escape(string)}'.match(#{js_regex}g);").to_a
end

def test_in_js(js_regex, string)
  eval_js("#{js_regex}.test('#{js_escape(string)}');")
end

def to_s_like_json(js_regex)
  json_string = js_escape(js_regex.to_json)
  eval_js <<-JS
    "use strict";

    var jsonObj = JSON.parse('#{json_string}');
    var jsonRE = new RegExp(jsonObj.source, jsonObj.options);
    var stringRE = #{js_regex};
    jsonRE.source == stringRE.source && jsonRE.flags == stringRE.flags;
  JS
end

def js_escape(string)
  string
    .gsub('\\') { '\\\\' } # this actually replaces one backslash with two
    .gsub("'") { "\\'" } # http://stackoverflow.com/revisions/12701027/2
    .gsub("\n", '\\n')
    .gsub("\r", '\\r')
end
