require 'assert'
require 'mr/factory/config'

require 'mr/factory/model_factory'
require 'mr/factory/record_factory'
require 'mr/fake_record'
require 'mr/model'

module MR::Factory::Config

  class UnitTests < Assert::Context
    desc "MR::Factory::Config"
    setup do
      @config_class = Class.new do
        include MR::Factory::Config
      end
    end
    subject{ @config_class }

  end

  class InitTests < UnitTests
    desc "when init"
    setup do
      @object_class = Factory.boolean ? FakeTwoRecord : TestReadModel

      @config = @config_class.new(@object_class)
    end
    subject{ @config }

    should have_readers :object_class
    should have_imeths :apply_args, :set_default_args

    should "know its object class" do
      assert_equal @object_class, subject.object_class
    end

    should "know its object builder class" do
      exp = MR::Factory::Config::ObjectBuilder
      assert_equal exp, subject.instance_eval{ object_builder_class }
    end

    should "know how to apply args" do
      object = @object_class.new

      call_order             = 0
      apply_hash_called_at   = nil
      apply_hash_called_with = nil
      Assert.stub(ObjectBuilder, :apply_hash) do |*args|
        apply_hash_called_at   = call_order += 1
        apply_hash_called_with = args
      end

      apply_proc_called_at   = nil
      apply_proc_called_with = nil
      Assert.stub(ObjectBuilder, :apply_proc) do |*args, &block|
        apply_proc_called_at   = call_order += 1
        apply_proc_called_with = args + [block]
      end

      args_hash = { :name => Factory.string }
      subject.apply_args(object, args_hash)

      assert_equal 1, apply_hash_called_at
      exp = [object, subject, args_hash]
      assert_equal exp, apply_hash_called_with

      assert_nil apply_proc_called_at
      assert_nil apply_proc_called_with

      call_order             = 0
      apply_hash_called_at   = nil
      apply_hash_called_with = nil

      # should use a default args proc if one is set
      default_args_proc = proc{ set :name, Factory.string }
      subject.set_default_args(&default_args_proc)

      subject.apply_args(object, args_hash)

      assert_equal 1, apply_proc_called_at
      exp = [object, subject, default_args_proc]
      assert_equal exp, apply_proc_called_with

      assert_equal 2, apply_hash_called_at
      exp = [object, subject, args_hash]
      assert_equal exp, apply_hash_called_with
    end

  end

  class ObjectBuilderTests < UnitTests
    desc "ObjectBuilder"
    setup do
      @object_class = [FakeTwoRecord, FakeTwoModel].sample

      @object = @object_class.new
      @config = @config_class.new(@object_class)

      @object_builder_class = ObjectBuilder
    end
    subject{ @object_builder_class }

    should have_imeths :apply_hash, :apply_proc

    should "know how to apply a hash and proc" do
      set_names_and_values = []
      Assert.stub(@object_builder_class, :new).with(@object, @config) do |*args|
        object_builder = Assert.stub_send(@object_builder_class, :new, @object, @config)
        Assert.stub(object_builder, :set) do |key, value|
          set_names_and_values << [key, value]
        end
        object_builder
      end

      hash = Factory.integer(3).times.inject({}) do |h, _|
        h.merge!(Factory.string => Factory.string)
      end

      subject.apply_hash(@object, @config, hash)

      exp = hash.map{ |(k, v)| [k, v] }.sort
      assert_equal exp, set_names_and_values.sort

      set_names_and_values = []

      subject.apply_proc(@object, @config) do
        hash.each{ |key, value| set(key, value) }
      end

      exp = hash.map{ |(k, v)| [k, v] }.sort
      assert_equal exp, set_names_and_values.sort
    end

  end

  class ObjectBuilderInitTests < ObjectBuilderTests
    desc "when init"
    setup do
      @object_builder = @object_builder_class.new(@object, @config)
    end
    subject{ @object_builder }

    should have_imeths :set

    should "know how to set an attribute on its object" do
      @object.name = Factory.string

      value = [Factory.string, nil].sample
      subject.set(:name, value)

      assert_equal value, @object.name
    end

  end

  class WithAssociationsConfigTests < Assert::Context
    desc "MR::Factory::WithAssociationsConfig"
    setup do
      @factory_class, @build_factory_proc = if Factory.boolean
        [ MR::Factory::RecordFactory,
          proc{ |rc| MR::Factory::RecordFactory.new(rc) }
        ]
      else
        [ MR::Factory::ModelFactory,
          proc{ |rc| MR::Factory::ModelFactory.new(rc.model_class, rc) }
        ]
      end

      build_factory_proc = @build_factory_proc
      @config_class = Class.new do
        include MR::Factory::WithAssociationsConfig
        define_method(:build_factory_for_record_class) do |record_class|
          build_factory_proc.call(record_class)
        end
        private :build_factory_for_record_class
      end
    end
    subject{ @config_class }

    should "be a factory config" do
      assert_includes MR::Factory::Config, subject
    end

  end

  class WithAssociationsConfigInitTests < WithAssociationsConfigTests
    desc "when init"
    setup do
      @record_class = FakeTwoRecord
      @config       = @config_class.new(@record_class)
    end
    subject{ @config }

    should have_imeths :record_class
    should have_imeths :force_in_stack_association_names, :force_in_stack_association?
    should have_imeths :default_factories, :association_factories
    should have_imeths :factories_for, :factory_for, :factory_config_for
    should have_imeths :record_classes_for, :build_associated_record
    should have_imeths :add_association_factory

    should "know its record class" do
      assert_equal @record_class, subject.record_class
    end

    should "know its force walk association names" do
      assert_equal [], subject.force_in_stack_association_names
    end

    should "know if it should force walk an association" do
      association_name = Factory.string
      assert_false subject.force_in_stack_association?(association_name)

      subject.force_in_stack_association_names << association_name

      assert_true subject.force_in_stack_association?(association_name)
    end

    should "know its object builder class" do
      exp = MR::Factory::WithAssociationsConfig::ObjectBuilder
      assert_equal exp, subject.instance_eval{ object_builder_class }
    end

    should "know its default factories" do
      assert_equal({}, subject.default_factories)

      factory_built_with = []
      Assert.stub(@factory_class, :new) do |*args|
        factory_built_with = args
        Assert.stub_send(@factory_class, :new, *args)
      end

      factory = subject.default_factories[FakeOneRecord]
      assert_instance_of @factory_class, factory
      if @factory_class == MR::Factory::RecordFactory
        assert_equal [FakeOneRecord], factory_built_with
      else
        assert_equal [FakeOneModel, FakeOneRecord], factory_built_with
      end

      # should cache the factories it builds
      assert_equal 1, subject.default_factories.size
      assert_same factory, subject.default_factories[FakeOneRecord]

      # should raise a no record class error if given a `nil` record class
      assert_raises(MR::Factory::NoRecordClassError) do
        subject.default_factories[nil]
      end
    end

    should "know its association factories" do
      assert_equal({}, subject.association_factories)
    end

    should "know how to add an association factory" do
      fake_one_factory = @build_factory_proc.call(FakeOneRecord)
      subject.add_association_factory(:fake_one, fake_one_factory)

      assert_true subject.default_factories.key?(FakeOneRecord)
      assert_equal fake_one_factory, subject.default_factories[FakeOneRecord]

      key = @config_class::AssociationFactoriesKey.new(:fake_one, FakeOneRecord)
      assert_true subject.association_factories.key?(key)
      assert_equal [fake_one_factory], subject.association_factories[key]
    end

    should "configure an association to be force walked when adding a factory for it" do
      fake_one_factory = @build_factory_proc.call(FakeOneRecord)
      subject.add_association_factory(:fake_one, fake_one_factory)

      assert_not_includes :fake_one, subject.force_in_stack_association_names

      subject.add_association_factory(:fake_one, fake_one_factory, :force_in_stack => true)
      assert_includes :fake_one, subject.force_in_stack_association_names
    end

    should "not duplicate force walk associations when adding a factory for it" do
      fake_one_factory = @build_factory_proc.call(FakeOneRecord)
      assert_empty subject.force_in_stack_association_names

      subject.add_association_factory(:fake_one, fake_one_factory, :force_in_stack => true)
      assert_equal 1, subject.force_in_stack_association_names.size

      subject.add_association_factory(:fake_one, fake_one_factory, :force_in_stack => true)
      assert_equal 1, subject.force_in_stack_association_names.size
    end

    should "not change a default once its set when adding an association factory" do
      fake_one_factory = @build_factory_proc.call(FakeOneRecord)
      subject.add_association_factory(:fake_one, fake_one_factory)

      other_fake_one_factory = @build_factory_proc.call(FakeOneRecord)
      subject.add_association_factory(:fake_one, other_fake_one_factory)

      default_factory = subject.default_factories[FakeOneRecord]
      assert_not_equal other_fake_one_factory, default_factory
      assert_equal     fake_one_factory,       default_factory
    end

    should "store all factories added for an association" do
      fake_one_factory = @build_factory_proc.call(FakeOneRecord)
      subject.add_association_factory(:fake_one, fake_one_factory)

      other_fake_one_factory = @build_factory_proc.call(FakeOneRecord)
      subject.add_association_factory(:fake_one, other_fake_one_factory)

      key = @config_class::AssociationFactoriesKey.new(:fake_one, FakeOneRecord)
      assert_equal 2, subject.association_factories[key].size
      assert_includes fake_one_factory,       subject.association_factories[key]
      assert_includes other_fake_one_factory, subject.association_factories[key]
    end

    should "know how to add a factory for a polymorphic association" do
      fake_one_factory = @build_factory_proc.call(FakeOneRecord)
      subject.add_association_factory(:fake_poly, fake_one_factory)

      assert_true subject.default_factories.key?(FakeOneRecord)
      assert_equal fake_one_factory, subject.default_factories[FakeOneRecord]

      # should add a `nil` record class association factory key
      key = @config_class::AssociationFactoriesKey.new(:fake_poly)
      assert_true subject.association_factories.key?(key)
      assert_equal [fake_one_factory], subject.association_factories[key]

      key = @config_class::AssociationFactoriesKey.new(:fake_poly, FakeOneRecord)
      assert_true subject.association_factories.key?(key)
      assert_equal [fake_one_factory], subject.association_factories[key]
    end

    should "error if adding a factory for an unknown association" do
      factory = @build_factory_proc.call(FakeOneRecord)

      assert_raises(MR::Factory::NoAssociationError) do
        subject.add_association_factory(Factory.string, factory)
      end
    end

    should "know how to get factories for an association and record class" do
      association_name = Factory.boolean ? :fake_one : :fake_poly

      factory = @build_factory_proc.call(FakeOneRecord)
      subject.add_association_factory(association_name, factory)

      assert_equal [factory], subject.factories_for(association_name, FakeOneRecord)

      other_factory = @build_factory_proc.call(FakeOneRecord)
      subject.add_association_factory(association_name, other_factory)

      factories = subject.factories_for(association_name, FakeOneRecord)

      assert_equal 2, factories.size
      assert_includes factory,       factories
      assert_includes other_factory, factories
    end

    should "return a default factory for an association if one isn't configured" do
      association_name = Factory.boolean ? :fake_one : :fake_poly

      exp = [subject.default_factories[FakeOneRecord]]
      assert_equal exp, subject.factories_for(association_name, FakeOneRecord)
    end

    should "know how to get a factory for an association and record class" do
      association_name   = Factory.boolean ? :fake_one : :fake_poly

      factories = Factory.integer(3).times.map do
        @build_factory_proc.call(FakeOneRecord)
      end
      Assert.stub(subject, :factories_for).with(
        association_name,
        FakeOneRecord
      ){ factories }

      factory = factories.sample
      Assert.stub(factories, :sample){ factory }

      assert_same factory, subject.factory_for(association_name, FakeOneRecord)
    end

    should "know how to get record classes for an association" do
      association_name = Factory.boolean ? :fake_one : :fake_poly

      factories = [
        @build_factory_proc.call(FakeOneRecord),
        @build_factory_proc.call(FakeThreeRecord)
      ]
      Assert.stub(subject, :factories_for).with(association_name, nil){ factories }

      exp = factories.map(&:record_class)
      assert_equal exp, subject.record_classes_for(association_name)
    end

  end

  class WithAssociationsConfigObjectBuilderTests < WithAssociationsConfigTests
    desc "ObjectBuilder"
    setup do
      @object_builder_class = MR::Factory::WithAssociationsConfig::ObjectBuilder
    end
    subject{ @object_builder_class }

    should "be a factory config object builder" do
      assert subject < MR::Factory::Config::ObjectBuilder
    end

  end

  class RecordObjectBuilderInitTests < WithAssociationsConfigObjectBuilderTests
    desc "when init with a record"
    setup do
      @config_class.class_eval do
        define_method(:ar_association_for) do |record, name|
          if (reflection = record.class.reflect_on_association(name))
            record.association(reflection.name)
          end
        end
        define_method(:build_factory_for_record_class) do |record_class|
          MR::Factory::RecordFactory.new(record_class)
        end
      end

      @record_class   = FakeTwoRecord
      @record         = @record_class.new
      @config         = @config_class.new(@record_class)
      @object_builder = @object_builder_class.new(@record, @config)
    end
    subject{ @object_builder }

    should "know how to set an association on its record" do
      @record.fake_one = FakeOneRecord.new

      value = [FakeOneRecord.new, nil].sample
      subject.set(:fake_one, value)

      assert_equal value, @record.fake_one

      @record.fake_poly = FakeOneRecord.new

      value = [FakeOneRecord.new, nil].sample
      subject.set(:fake_poly, value)

      assert_equal value, @record.fake_poly
    end

    should "know how to set an association when given a hash" do
      associated_record_class = FakeOneRecord
      association_name        = Factory.boolean ? :fake_one : :fake_poly
      associated_factory      = MR::Factory::RecordFactory.new(associated_record_class)
      if Factory.boolean
        @config.add_association_factory(association_name, associated_factory)
      else
        @config.default_factories[associated_record_class] = associated_factory
      end

      apply_hash_called_with = nil
      Assert.stub(@object_builder_class, :apply_hash) do |*args|
        apply_hash_called_with = args
        Assert.stub_send(@object_builder_class, :apply_hash, *args)
      end

      # when the association is already set
      associated_record = associated_factory.record
      @record.send("#{association_name}=", associated_record)

      association_args = { :name => Factory.string }
      subject.set(association_name, association_args)

      assert_equal associated_record,       @record.send(association_name)
      assert_equal association_args[:name], associated_record.name

      exp = [associated_record, associated_factory.config, association_args]
      assert_equal exp, apply_hash_called_with

      # when the association isn't already set
      @record.send("#{association_name}=", nil)
      if association_name == :fake_poly
        # set the fake poly type attribute so it can find a factory for it
        @record.fake_poly_type = associated_record_class.to_s
      end

      associated_record = associated_factory.record
      Assert.stub(associated_factory, :instance){ associated_record }

      association_args = { :name => Factory.string }
      subject.set(association_name, association_args)

      assert_equal associated_record,       @record.send(association_name)
      assert_equal association_args[:name], associated_record.name

      exp = [associated_record, associated_factory.config, association_args]
      assert_equal exp, apply_hash_called_with
    end

    should "raise a record class error if it can't build an association" do
      assert_raises(MR::Factory::NoRecordClassError) do
        subject.set(:fake_poly, {})
      end
    end

  end

  class ModelObjectBuilderInitTests < WithAssociationsConfigObjectBuilderTests
    desc "when init with a model"
    setup do
      @config_class.class_eval do
        define_method(:ar_association_for) do |model, name|
          if (reflection = model.record_class.reflect_on_association(name))
            model.record.association(reflection.name)
          end
        end
        define_method(:build_factory_for_record_class) do |record_class|
          MR::Factory::ModelFactory.new(record_class.model_class, record_class)
        end
      end

      @model_class    = FakeTwoModel
      @model          = @model_class.new
      @config         = @config_class.new(@model_class.record_class)
      @object_builder = @object_builder_class.new(@model, @config)
    end
    subject{ @object_builder }

    should "know how to set an association on its model" do
      @model.fake_one = FakeOneModel.new

      value = [FakeOneModel.new, nil].sample
      subject.set(:fake_one, value)

      assert_equal value, @model.fake_one

      @model.fake_poly = FakeOneModel.new

      value = [FakeOneModel.new, nil].sample
      subject.set(:fake_poly, value)

      assert_equal value, @model.fake_poly
    end

    should "know how to set an association when given a hash" do
      associated_model_class = FakeOneModel
      association_name       = Factory.boolean ? :fake_one : :fake_poly
      associated_factory     = MR::Factory::ModelFactory.new(
        associated_model_class,
        associated_model_class.record_class
      )
      if Factory.boolean
        @config.add_association_factory(association_name, associated_factory)
      else
        record_class = associated_model_class.record_class
        @config.default_factories[record_class] = associated_factory
      end

      apply_hash_called_with = nil
      Assert.stub(@object_builder_class, :apply_hash) do |*args|
        apply_hash_called_with = args
        Assert.stub_send(@object_builder_class, :apply_hash, *args)
      end

      # when the association is already set
      associated_model = associated_factory.model
      @model.send("#{association_name}=", associated_model)

      association_args = { :name => Factory.string }
      subject.set(association_name, association_args)

      assert_equal associated_model,        @model.send(association_name)
      assert_equal association_args[:name], associated_model.name

      exp = [associated_model, associated_factory.config, association_args]
      assert_equal exp, apply_hash_called_with

      # when the association isn't already set
      @model.send("#{association_name}=", nil)
      if association_name == :fake_poly
        # set the fake poly type attribute so it can find a factory for it
        @model.fake_poly_type = associated_model_class.record_class.to_s
      end

      associated_model = associated_factory.model
      Assert.stub(associated_factory, :instance){ associated_model }

      association_args = { :name => Factory.string }
      subject.set(association_name, association_args)

      assert_equal associated_model,       @model.send(association_name)
      assert_equal association_args[:name], associated_model.name

      exp = [associated_model, associated_factory.config, association_args]
      assert_equal exp, apply_hash_called_with
    end

    should "raise a record class error if it can't build an association" do
      assert_raises(MR::Factory::NoRecordClassError) do
        subject.set(:fake_poly, {})
      end
    end

  end

  class FakeOneRecord
    include MR::FakeRecord

    attribute :name, :string
  end

  class FakeTwoRecord
    include MR::FakeRecord

    attribute :name,           :string
    attribute :fake_poly_type, :string
    attribute :fake_poly_id,   :integer
    attribute :fake_one_id,    :integer

    belongs_to :fake_poly, :polymorphic => true
    belongs_to :fake_one,  :class_name  => FakeOneRecord.to_s
  end

  class FakeThreeRecord
    include MR::FakeRecord

    attribute :fake_one_id, :integer
    attribute :fake_two_id, :integer

    belongs_to :fake_one, :class_name => FakeOneRecord.to_s
    belongs_to :fake_two, :class_name => FakeTwoRecord.to_s
  end

  class FakeOneModel
    include MR::Model
    record_class FakeOneRecord

    field_accessor :name
  end

  class FakeTwoModel
    include MR::Model
    record_class FakeTwoRecord

    field_accessor :name, :fake_poly_type, :fake_poly_id, :fake_one_id

    polymorphic_belongs_to :fake_poly
    belongs_to :fake_one
  end

  class TestReadModel
    include MR::ReadModel

    field :name, :string
  end

end
