module Softwear
  module Library
    module SpecDump
      Record = Struct.new(:object, :history) do
        def model; object.class; end
      end

      def spec_dump(root, ignored_models = [], whitelist_array = nil)
        types_recorded = {}
        records_by_type = {}
        record_queue = Queue.new
        whitelist = whitelist_array ? whitelist_array.reduce({}) { |h,n| h.merge(n => true) } : Hash.new(true)

        is_ignored = lambda do |name|
          next true if ignored_models.any? { |i| name =~ i }
          next true if !whitelist[name]
          false
        end

        # Begin dump routine. This will return true when we added a new entry.
        dump = lambda do |record|
          cache = (records_by_type[record.model.name] ||= {})
          identifier = record.object.id

          next false if cache[identifier].present?

          attributes = {}
          record.model.column_names.each do |col|
            value = record.object[col]
            if value.respond_to?(:iso8601)
              # DateTimes don't serialize properly in attributes_before_type_cast
              # for some reason, so we explicitly call to_s(:db) to make sure
              # they can be loaded again correctly.
              raw_value = value.to_s(:db)
            else
              raw_value = record.object.attributes_before_type_cast[col]
            end

            attributes[col] = raw_value
          end

          cache[identifier] = attributes
          true
        end
        # end dump routine

        # Begin actual dumping of records
        Array(root).each do |record|
          record_queue << Record.new(record, ["#{record.class.name}##{record.id}"])
        end

        while record_queue.present?
          record = record_queue.pop
          next if record.object.nil?
          next unless record.model.respond_to?(:column_names)

          # If dump returns false, that means we've already dumped this record
          next unless dump.(record)
          types_recorded[record.model.name] = true

          yield record, types_recorded if block_given?

          record.model.reflect_on_all_associations.each do |assoc|
            next if assoc.is_a?(ActiveRecord::Reflection::ThroughReflection)

            next if is_ignored["#{record.model.name}##{assoc.name}"]

            case assoc
            when ActiveRecord::Reflection::BelongsToReflection
              # A belongs_to association will never cause an infinite loop
              record_queue << Record.new(
                record.object.send(assoc.name),
                record.history + ["#{record.model.name}##{record.object.id}##{assoc.name}"]
              )

            when ActiveRecord::Reflection::HasManyReflection
              # A has_many association can cause an infinite loop, so we only
              # process these if we've never seen the record type before.
              #
              # If there's a whitelist, no need to care about that
              next if whitelist_array.blank? && types_recorded[assoc.klass.name]

              record.object.send(assoc.name).each_with_index do |child, i|
                next if child.nil?
                record_queue << Record.new(
                  child,
                  record.history + ["#{record.model.name}##{record.object.id}##{assoc.name}[#{i}]"]
                )
              end
            end
          end
        end
        # end actual dumping of records

        records_by_type
      end

      def expand_spec_dump(dump, use_outside_of_test = false)
        if !Rails.env.test? && !use_outside_of_test
          raise "Tried to call expand_spec_dump outside of test environment. "\
                "If you really want to do this, pass `true` as the second parameter."
        end

        if ActiveRecord::Base.configurations[Rails.env]['adapter'].include?('sqlite')
          insert_cmd = lambda do |model|
            "INSERT OR REPLACE INTO #{model.table_name} (#{model.column_names.map { |c| "`#{c}`" }.join(', ')}) VALUES\n"
          end
          cmd_suffix = ->_{ "" }
        else
          insert_cmd = lambda do |model|
            "INSERT INTO #{model.table_name} (#{model.column_names.map { |c| "`#{c}`" }.join(', ')}) VALUES\n"
          end
          cmd_suffix = lambda do |model|
            "\nON DUPLICATE KEY UPDATE\n" +
              model.column_names
                .map { |col| "`#{col}` = VALUES(`#{col}`)" }
                .join(",\n")
          end
        end

        dump.each do |class_name, entries|
          model    = class_name.constantize
          sql      = insert_cmd[model]
          sanitize = model.connection.method(:quote)

          sql += entries.map do |entry|
            _id, attributes = entry

            '(' +
              model.column_names.map { |col| sanitize[attributes[col]] }.join(', ') +
            ')'
          end.join(",\n")

          sql += cmd_suffix[model]

          model.connection.execute sql
        end
      end

      extend self
    end
  end
end