require 'spec_helper'

RSpec.describe 'Enum' do
  let(:connection) { ActiveRecord::Base.connection }
  let(:attribute_klass) { Torque::PostgreSQL::Attributes::Enum }

  def decorate(model, field, options = {})
    attribute_klass.include_on(model, :pg_enum)
    model.pg_enum(field, **options)
  end

  before :each do
    Torque::PostgreSQL.config.enum.base_method = :pg_enum
    Torque::PostgreSQL::Attributes::Enum.include_on(ActiveRecord::Base)

    # Define a method to find yet to define constants
    Torque::PostgreSQL.config.enum.namespace.define_singleton_method(:const_missing) do |name|
      Torque::PostgreSQL::Attributes::Enum.lookup(name)
    end

    # Define a helper method to get a sample value
    Torque::PostgreSQL.config.enum.namespace.define_singleton_method(:sample) do |name|
      Torque::PostgreSQL::Attributes::Enum.lookup(name).sample
    end
  end

  context 'on migration' do
    it 'can be created' do
      connection.create_enum(:status, %i(foo bar))
      expect(connection.type_exists?(:status)).to be_truthy
      expect(connection.enum_values(:status)).to be_eql(['foo', 'bar'])
    end

    it 'can be deleted' do
      connection.create_enum(:status, %i(foo bar))
      expect(connection.type_exists?(:status)).to be_truthy

      connection.drop_type(:status)
      expect(connection.type_exists?(:status)).to be_falsey
    end

    it 'can be renamed' do
      connection.rename_type(:content_status, :status)
      expect(connection.type_exists?(:content_status)).to be_falsey
      expect(connection.type_exists?(:status)).to be_truthy
    end

    it 'can have prefix' do
      connection.create_enum(:status, %i(foo bar), prefix: true)
      expect(connection.enum_values(:status)).to be_eql(['status_foo', 'status_bar'])
    end

    it 'can have suffix' do
      connection.create_enum(:status, %i(foo bar), suffix: 'tst')
      expect(connection.enum_values(:status)).to be_eql(['foo_tst', 'bar_tst'])
    end

    it 'inserts values at the end' do
      connection.create_enum(:status, %i(foo bar))
      connection.add_enum_values(:status, %i(baz qux))
      expect(connection.enum_values(:status)).to be_eql(['foo', 'bar', 'baz', 'qux'])
    end

    it 'inserts values in the beginning' do
      connection.create_enum(:status, %i(foo bar))
      connection.add_enum_values(:status, %i(baz qux), prepend: true)
      expect(connection.enum_values(:status)).to be_eql(['baz', 'qux', 'foo', 'bar'])
    end

    it 'inserts values in the middle' do
      connection.create_enum(:status, %i(foo bar))
      connection.add_enum_values(:status, %i(baz), after: 'foo')
      expect(connection.enum_values(:status)).to be_eql(['foo', 'baz', 'bar'])

      connection.add_enum_values(:status, %i(qux), before: 'bar')
      expect(connection.enum_values(:status)).to be_eql(['foo', 'baz', 'qux', 'bar'])
    end

    it 'inserts values with prefix or suffix' do
      connection.create_enum(:status, %i(foo bar))
      connection.add_enum_values(:status, %i(baz), prefix: true)
      connection.add_enum_values(:status, %i(qux), suffix: 'tst')
      expect(connection.enum_values(:status)).to be_eql(['foo', 'bar', 'status_baz', 'qux_tst'])
    end
  end

  context 'on table definition' do
    subject { ActiveRecord::ConnectionAdapters::PostgreSQL::TableDefinition.new('articles') }

    it 'has the enum method' do
      expect(subject).to respond_to(:enum)
    end

    it 'can be used in a single form' do
      subject.enum('content_status')
      expect(subject['content_status'].name).to be_eql('content_status')
      expect(subject['content_status'].type).to be_eql(:content_status)
    end

    it 'can be used in a multiple form' do
      subject.enum('foo', 'bar', 'baz', subtype: :content_status)
      expect(subject['foo'].type).to be_eql(:content_status)
      expect(subject['bar'].type).to be_eql(:content_status)
      expect(subject['baz'].type).to be_eql(:content_status)
    end

    it 'can have custom type' do
      subject.enum('foo', subtype: :content_status)
      expect(subject['foo'].name).to be_eql('foo')
      expect(subject['foo'].type).to be_eql(:content_status)
    end

    it 'raises StatementInvalid when type isn\'t defined' do
      subject.enum('foo')
      creation = connection.send(:schema_creation).accept subject
      expect{ connection.execute creation }.to raise_error(ActiveRecord::StatementInvalid)
    end
  end

  context 'on schema' do
    it 'dumps when has it' do
      dump_io = StringIO.new
      ActiveRecord::SchemaDumper.dump(connection, dump_io)
      expect(dump_io.string).to match /create_enum \"content_status\", \[/
    end

    it 'sorts the enum entries to better consistency' do
      dump_io = StringIO.new
      ActiveRecord::SchemaDumper.dump(connection, dump_io)
      items = dump_io.string.scan(/create_enum "(\w+)"/).flatten
      expect(items).to be_eql(items.sort)
    end

    it 'do not dump when has none' do
      connection.drop_type(:content_status, force: :cascade)

      dump_io = StringIO.new
      ActiveRecord::SchemaDumper.dump(connection, dump_io)
      expect(dump_io.string).not_to match /create_enum \"content_status\", \[/
    end

    it 'can be used on tables too' do
      dump_io = StringIO.new
      ActiveRecord::SchemaDumper.dump(connection, dump_io)
      expect(dump_io.string).to match /t\.enum +"status", +subtype: :content_status/
    end

    it 'can have a default value as symbol' do
      dump_io = StringIO.new
      ActiveRecord::SchemaDumper.dump(connection, dump_io)
      expect(dump_io.string).to match /t\.enum +"role", +default: :visitor, +subtype: :roles/
    end
  end

  context 'on value' do
    subject { Enum::ContentStatus }
    let(:values) { %w(created draft published archived) }
    let(:error) { Torque::PostgreSQL::Attributes::Enum::EnumError }
    let(:mock_enum) do
      klass = Class.new(subject.superclass)
      klass.instance_variable_set(:@values, values << '15')
      klass
    end

    it 'class exists' do
      namespace = Torque::PostgreSQL.config.enum.namespace
      expect(namespace.const_defined?('ContentStatus')).to be_truthy
      expect(subject < Torque::PostgreSQL::Attributes::Enum).to be_truthy
    end

    it 'lazy loads values' do
      expect(subject.instance_variable_defined?(:@values)).to be_falsey
    end

    it 'returns the db type name' do
      expect(subject.type_name).to be_eql('content_status')
    end

    it 'values match database values' do
      expect(subject.values).to be_eql(values)
    end

    it 'can return a sample value' do
      expect(Enum).to respond_to(:sample)
      expect(Enum::ContentStatus).to respond_to(:sample)
      expect(Enum::ContentStatus.sample).to satisfy { |v| values.include?(v) }
      expect(Enum.sample(:content_status)).to satisfy { |v| values.include?(v) }
    end

    it 'values can be iterated by using each direct on class' do
      expect(subject).to respond_to(:each)
      expect(subject.each).to be_a(Enumerator)
      expect(subject.each.entries).to be_eql(values)
    end

    it 'values can be reach using fetch, as in hash enums' do
      expect(subject).to respond_to(:fetch)

      value = subject.fetch('archived', 'archived')
      expect(value).to be_a(subject)
      expect(value).to be_eql(subject.archived)

      value = subject.fetch('other', 'other')
      expect(value).to be_nil
    end

    it 'values can be reach using [], as in hash enums' do
      expect(subject).to respond_to(:[])

      value = subject['archived']
      expect(value).to be_a(subject)
      expect(value).to be_eql(subject.archived)

      value = subject['other']
      expect(value).to be_nil
    end

    it 'accepts respond_to against value' do
      expect(subject).to respond_to(:archived)
    end

    it 'allows fast creation of values' do
      value = subject.draft
      expect(value).to be_a(subject)
    end

    it 'keeps blank values as Lazy' do
      expect(subject.new(nil)).to be_nil
      expect(subject.new([])).to be_nil
      expect(subject.new('')).to be_nil
    end

    it 'can start from nil value using lazy' do
      lazy  = Torque::PostgreSQL::Attributes::Lazy
      value = subject.new(nil)

      expect(value.__class__).to be_eql(lazy)
      expect(value.to_s).to be_eql('')
      expect(value.to_i).to be_nil

      expect(value.draft?).to be_falsey
    end

    it 'accepts values to come from numeric' do
      expect(subject.new(0)).to be_eql(subject.created)
      expect { subject.new(5) }.to raise_error(error, /out of bounds/)
    end

    it 'accepts string initialization' do
      expect(subject.new('created')).to be_eql(subject.created)
      expect { subject.new('updated') }.to raise_error(error, /not valid for/)
    end

    it 'allows values comparison' do
      value = subject.draft
      expect(value).to be > subject.created
      expect(value).to be < subject.archived
      expect(value).to be_eql(subject.draft)
      expect(value).to_not be_eql(subject.published)
    end

    it 'allows values comparison with string' do
      value = subject.draft
      expect(value).to be > :created
      expect(value).to be < :archived
      expect(value).to be_eql(:draft)
      expect(value).to_not be_eql(:published)
    end

    it 'allows values comparison with symbol' do
      value = subject.draft
      expect(value).to be > 'created'
      expect(value).to be < 'archived'
      expect(value).to be_eql('draft')
      expect(value).to_not be_eql('published')
    end

    it 'allows values comparison with number' do
      value = subject.draft
      expect(value).to be > 0
      expect(value).to be < 3
      expect(value).to be_eql(1)
      expect(value).to_not be_eql(2.5)
    end

    it 'does not allow cross-enum comparison' do
      expect { subject.draft < mock_enum.published }.to raise_error(error, /^Comparison/)
      expect { subject.draft > mock_enum.created }.to raise_error(error, /^Comparison/)
    end

    it 'does not allow other types comparison' do
      expect { subject.draft > true }.to raise_error(error, /^Comparison/)
      expect { subject.draft < [] }.to raise_error(error, /^Comparison/)
    end

    it 'accepts value checking' do
      value = subject.draft
      expect(value).to respond_to(:archived?)
      expect(value.draft?).to be_truthy
      expect(value.published?).to be_falsey
    end

    it 'accepts replace and bang value' do
      value = subject.draft
      expect(value).to respond_to(:archived!)
      expect(value.archived!).to be_eql(subject.archived)
      expect(value.replace('created')).to be_eql(subject.created)
    end

    it 'accepts values turn into integer by its index' do
      mock_value = mock_enum.new('15')
      expect(subject.created.to_i).to be_eql(0)
      expect(subject.archived.to_i).to be_eql(3)
      expect(mock_value.to_i).to_not be_eql(15)
      expect(mock_value.to_i).to be_eql(4)
    end

    context 'on members' do
      it 'has enumerable operations' do
        expect(subject).to respond_to(:all?)
        expect(subject).to respond_to(:any?)
        expect(subject).to respond_to(:collect)
        expect(subject).to respond_to(:count)
        expect(subject).to respond_to(:cycle)
        expect(subject).to respond_to(:detect)
        expect(subject).to respond_to(:drop)
        expect(subject).to respond_to(:drop_while)
        expect(subject).to respond_to(:each)
        expect(subject).to respond_to(:each_with_index)
        expect(subject).to respond_to(:entries)
        expect(subject).to respond_to(:find)
        expect(subject).to respond_to(:find_all)
        expect(subject).to respond_to(:find_index)
        expect(subject).to respond_to(:first)
        expect(subject).to respond_to(:flat_map)
        expect(subject).to respond_to(:include?)
        expect(subject).to respond_to(:inject)
        expect(subject).to respond_to(:lazy)
        expect(subject).to respond_to(:map)
        expect(subject).to respond_to(:member?)
        expect(subject).to respond_to(:one?)
        expect(subject).to respond_to(:reduce)
        expect(subject).to respond_to(:reject)
        expect(subject).to respond_to(:reverse_each)
        expect(subject).to respond_to(:select)
        expect(subject).to respond_to(:sort)
        expect(subject).to respond_to(:zip)
      end

      it 'works with map' do
        result = subject.map(&:to_i)
        expect(result).to be_eql([0, 1, 2, 3])
      end
    end
  end

  context 'on OID' do
    let(:enum) { Enum::ContentStatus }
    subject { Torque::PostgreSQL::Adapter::OID::Enum.new('content_status') }

    context 'on deserialize' do
      it 'returns nil' do
        expect(subject.deserialize(nil)).to be_nil
      end

      it 'returns enum' do
        value = subject.deserialize('created')
        expect(value).to be_a(enum)
        expect(value).to be_eql(enum.created)
      end
    end

    context 'on serialize' do
      it 'returns nil' do
        expect(subject.serialize(nil)).to be_nil
        expect(subject.serialize('test')).to be_nil
        expect(subject.serialize(15)).to be_nil
      end

      it 'returns as string' do
        expect(subject.serialize(enum.created)).to be_eql('created')
        expect(subject.serialize(1)).to be_eql('draft')
      end
    end

    context 'on cast' do
      it 'accepts nil' do
        expect(subject.cast(nil)).to be_nil
      end

      it 'accepts invalid values as nil' do
        expect(subject.cast(false)).to be_nil
        expect(subject.cast(true)).to be_nil
        expect(subject.cast([])).to be_nil
      end

      it 'accepts string' do
        value = subject.cast('created')
        expect(value).to be_a(enum)
        expect(value).to be_eql(enum.created)
      end

      it 'accepts numeric' do
        value = subject.cast(1)
        expect(value).to be_a(enum)
        expect(value).to be_eql(enum.draft)
      end
    end
  end

  context 'on I18n' do
    subject { Enum::ContentStatus }

    it 'has the text method' do
      expect(subject.new(0)).to respond_to(:text)
    end

    it 'brings the correct values' do
      expect(subject.new(0).text).to be_eql('1 - Created')
      expect(subject.new(1).text).to be_eql('Draft (2)')
      expect(subject.new(2).text).to be_eql('Finally published')
      expect(subject.new(3).text).to be_eql('Archived')
    end
  end

  context 'on model' do
    before(:each) { decorate(User, :role) }

    subject { User }
    let(:instance) { FactoryBot.build(:user) }

    it 'has all enum methods' do
      expect(subject).to  respond_to(:roles)
      expect(subject).to  respond_to(:roles_keys)
      expect(subject).to  respond_to(:roles_texts)
      expect(subject).to  respond_to(:roles_options)
      expect(instance).to respond_to(:role_text)

      subject.roles.each do |value|
        expect(subject).to  respond_to(value)
        expect(instance).to respond_to(value + '?')
        expect(instance).to respond_to(value + '!')
      end
    end

    it 'plural method brings the list of values' do
      result = subject.roles
      expect(result).to be_a(Array)
      expect(result).to be_eql(Enum::Roles.values)
    end

    it 'text value now uses model and attribute references' do
      instance.role = :visitor
      expect(instance.role_text).to be_eql('A simple Visitor')

      instance.role = :assistant
      expect(instance.role_text).to be_eql('An Assistant')

      instance.role = :manager
      expect(instance.role_text).to be_eql('The Manager')

      instance.role = :admin
      expect(instance.role_text).to be_eql('Super Duper Admin')
    end

    it 'has scopes correctly applied' do
      subject.roles.each do |value|
        expect(subject.send(value).to_sql).to match(/WHERE "users"."role" = '#{value}'/)
      end
    end

    it 'has scopes available on associations' do
      author = FactoryBot.create(:author)
      FactoryBot.create(:post, author: author)

      decorate(Post, :status)
      expect(author.posts).to respond_to(:test_scope)

      Enum::ContentStatus.each do |value|
        expect(author.posts).to be_a(ActiveRecord::Associations::CollectionProxy)
        expect(author.posts).to respond_to(value.to_sym)
        expect(author.posts.send(value).to_sql).to match(/AND "posts"."status" = '#{value}'/)
      end
    end

    it 'ask methods work' do
      instance.role = :assistant
      expect(instance.manager?).to be_falsey
      expect(instance.assistant?).to be_truthy
    end

    it 'bang methods work' do
      instance.admin!
      expect(instance.persisted?).to be_truthy

      updated_at = instance.updated_at
      Torque::PostgreSQL.config.enum.save_on_bang = false
      instance.visitor!
      Torque::PostgreSQL.config.enum.save_on_bang = true

      expect(instance.role).to be_eql(:visitor)
      expect(instance.updated_at).to be_eql(updated_at)

      instance.reload
      expect(instance.role).to be_eql(:admin)
    end

    it 'raises when starting an enum with conflicting methods' do
      Torque::PostgreSQL.config.enum.raise_conflicting = true
      AText = Class.new(ActiveRecord::Base)
      AText.table_name = 'texts'

      expect { decorate(AText, :conflict) }.to raise_error(ArgumentError, /already exists in/)
      Torque::PostgreSQL.config.enum.raise_conflicting = false
    end

    it 'scope the model correctly' do
      query = subject.manager.to_sql
      expect(query).to match(/"users"."role" = 'manager'/)
    end

    context 'on inherited classes' do
      it 'has all enum methods' do
        klass = Class.new(User)
        instance = klass.new

        expect(klass).to    respond_to(:roles)
        expect(klass).to    respond_to(:roles_keys)
        expect(klass).to    respond_to(:roles_texts)
        expect(klass).to    respond_to(:roles_options)
        expect(instance).to respond_to(:role_text)

        klass.roles.each do |value|
          expect(klass).to    respond_to(value)
          expect(instance).to respond_to(value + '?')
          expect(instance).to respond_to(value + '!')
        end
      end
    end

    context 'without autoload' do
      subject { Author }
      let(:instance) { FactoryBot.build(:author) }

      it 'has both rails original enum and the new pg_enum' do
        expect(subject).to respond_to(:enum)
        expect(subject).to respond_to(:pg_enum)
        expect(subject.method(:pg_enum).arity).to eql(-1)
      end

      it 'does not create all methods' do
        AAuthor = Class.new(ActiveRecord::Base)
        AAuthor.table_name = 'authors'

        expect(AAuthor).to_not respond_to(:specialties)
        expect(AAuthor).to_not respond_to(:specialties_keys)
        expect(AAuthor).to_not respond_to(:specialties_texts)
        expect(AAuthor).to_not respond_to(:specialties_options)
        expect(AAuthor.instance_methods).to_not include(:specialty_text)

        Enum::Specialties.values.each do |value|
          expect(AAuthor).to_not respond_to(value)
          expect(AAuthor.instance_methods).to_not include(value + '?')
          expect(AAuthor.instance_methods).to_not include(value + '!')
        end
      end

      it 'can be manually initiated' do
        decorate(Author, :specialty)
        expect(subject).to  respond_to(:specialties)
        expect(subject).to  respond_to(:specialties_keys)
        expect(subject).to  respond_to(:specialties_texts)
        expect(subject).to  respond_to(:specialties_options)
        expect(instance).to respond_to(:specialty_text)

        Enum::Specialties.values.each do |value|
          expect(subject).to  respond_to(value)
          expect(instance).to respond_to(value + '?')
          expect(instance).to respond_to(value + '!')
        end
      end
    end

    context 'with prefix' do
      before(:each) { decorate(Author, :specialty, prefix: 'in') }
      subject { Author }
      let(:instance) { FactoryBot.build(:author) }

      it 'creates all methods correctly' do
        expect(subject).to  respond_to(:specialties)
        expect(subject).to  respond_to(:specialties_keys)
        expect(subject).to  respond_to(:specialties_texts)
        expect(subject).to  respond_to(:specialties_options)
        expect(instance).to respond_to(:specialty_text)

        subject.specialties.each do |value|
          expect(subject).to  respond_to('in_' + value)
          expect(instance).to respond_to('in_' + value + '?')
          expect(instance).to respond_to('in_' + value + '!')
        end
      end
    end

    context 'with suffix, only, and except' do
      before(:each) do
        decorate(Author, :specialty, suffix: 'expert', only: %w(books movies), except: 'books')
      end

      subject { Author }
      let(:instance) { FactoryBot.build(:author) }

      it 'creates only the requested methods' do
        expect(subject).to  respond_to('movies_expert')
        expect(instance).to respond_to('movies_expert?')
        expect(instance).to respond_to('movies_expert!')

        expect(subject).to_not  respond_to('books_expert')
        expect(instance).to_not respond_to('books_expert?')
        expect(instance).to_not respond_to('books_expert!')

        expect(subject).to_not  respond_to('plays_expert')
        expect(instance).to_not respond_to('plays_expert?')
        expect(instance).to_not respond_to('plays_expert!')

      end
    end
  end
end
