require "rainbow"
require "json"

# A shitlist for deprecation warnings during test runs. It has two modes: "save" and "compare"
#
# DEPRECATION_TRACKER=save
# Record deprecation warnings, grouped by spec file. After the test run, save to a file.
#
# DEPRECATION_TRACKER=compare
# Tracks deprecation warnings, grouped by spec file. After the test run, compare against shitlist of expected
# deprecation warnings. If anything is added or removed, raise an error with a diff of the changes.
#
class DeprecationTracker
  UnexpectedDeprecations = Class.new(StandardError)

  module KernelWarnTracker
    def self.callbacks
      @callbacks ||= []
    end

    def warn(*messages)
      KernelWarnTracker.callbacks.each do |callback|
        messages.each { |message| callback.(message) }
      end

      super
    end
  end

  # There are two forms of the `warn` method: one for class Kernel and one for instances of Kernel (i.e., every Object)
  Object.prepend(KernelWarnTracker)

  # Ruby 2.2 and lower doesn't appear to allow overriding of Kernel.warn using `singleton_class.prepend`.
  if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.3.0")
    Kernel.singleton_class.prepend(KernelWarnTracker)
  else
    def Kernel.warn(*args, &block)
      Object.warn(*args, &block)
    end
  end

  def self.track_rspec(rspec_config, opts = {})
    shitlist_path = opts[:shitlist_path]
    mode = opts[:mode]
    transform_message = opts[:transform_message]
    deprecation_tracker = DeprecationTracker.new(shitlist_path, transform_message)
    if defined?(ActiveSupport)
      ActiveSupport::Deprecation.behavior << -> (message, _callstack, _deprecation_horizon, _gem_name) { deprecation_tracker.add(message) }
    end
    KernelWarnTracker.callbacks << -> (message) { deprecation_tracker.add(message) }

    rspec_config.around do |example|
      deprecation_tracker.bucket = example.metadata.fetch(:rerun_file_path)

      begin
        example.run
      ensure
        deprecation_tracker.bucket = nil
      end
    end

    rspec_config.after(:suite) do
      if mode == "save"
        deprecation_tracker.save
      elsif mode == "compare"
        deprecation_tracker.compare
      end
    end
  end

  attr_reader :deprecation_messages, :shitlist_path, :transform_message
  attr_reader :bucket

  def initialize(shitlist_path, transform_message = nil)
    @shitlist_path = shitlist_path
    @transform_message = transform_message || -> (message) { message }
    @deprecation_messages = {}
  end

  def add(message)
    return if bucket.nil?

    @deprecation_messages[bucket] << transform_message.(message)
  end

  def bucket=(value)
    @bucket = value
    @deprecation_messages[value] ||= [] unless value.nil?
  end

  def compare
    shitlist = read_shitlist

    changed_buckets = []
    normalized_deprecation_messages.each do |bucket, messages|
      if shitlist[bucket] != messages
        changed_buckets << bucket
      end
    end

    if changed_buckets.length > 0
      message = Rainbow(<<-MESSAGE).red
        ⚠️  Deprecation warnings have changed!

        Code called by the following spec files is now generating different deprecation warnings:

        #{changed_buckets.join("\n")}

        To check your failures locally, you can run:

        DEPRECATION_TRACKER=compare bundle exec rspec #{changed_buckets.join(" ")}

        Here is a diff between what is expected and what was generated by this process:

        #{diff}

        See \e[4;37mdev-docs/testing/deprecation_tracker.md\e[0;31m for more information.
      MESSAGE

      raise UnexpectedDeprecations, message
    end
  end

  def diff
    new_shitlist = create_temp_shitlist
    `git diff --no-index #{shitlist_path} #{new_shitlist.path}`
  ensure
    new_shitlist.delete
  end

  def save
    new_shitlist = create_temp_shitlist
    FileUtils.cp(new_shitlist.path, shitlist_path)
  ensure
    new_shitlist.delete if new_shitlist
  end

  def create_temp_shitlist
    temp_file = Tempfile.new("temp-deprecation-tracker-shitlist")
    temp_file.write(JSON.pretty_generate(normalized_deprecation_messages))
    temp_file.flush

    temp_file
  end

  # Normalize deprecation messages to reduce noise from file output and test files to be tracked with separate test runs
  def normalized_deprecation_messages
    normalized = read_shitlist.merge(deprecation_messages).each_with_object({}) do |(bucket, messages), hash|
      hash[bucket] = messages.sort
    end

    normalized.reject {|_key, value| value.empty? }.sort_by {|key, _value| key }.to_h
  end

  def read_shitlist
    return {} unless File.exist?(shitlist_path)
    JSON.parse(File.read(shitlist_path))
  rescue JSON::ParserError => e
    raise "#{shitlist_path} is not valid JSON: #{e.message}"
  end
end
