SpeciesFileGroup/taxonworks

View on GitHub
app/models/protonym.rb

Summary

Maintainability
A
0 mins
Test Coverage
# Nested ness of this should get all the relationships?
require_dependency Rails.root.to_s + '/app/models/taxon_name_relationship.rb'


# A *monomial* TaxonName, a record implies a first usage. This follows Pyle's concept almost exactly.
#
# We inject a lot of relationship helper methods here, in this format.
#   subject                      object
#   Aus      original_genus of   bus
#   aus      type_species of     Bus
#
class Protonym < TaxonName

  extend Protonym::SoftValidationExtensions::Klass
  include Protonym::SoftValidationExtensions::Instance
  include Protonym::Becomes

  # @return [Boolean]
  #   memoize `#is_avaiable?`
  attr_reader :is_available

  alias_method :original_combination_source, :source

  FAMILY_GROUP_ENDINGS = %w{ini ina inae idae oidae odd ad oidea}.freeze

  validates_presence_of :name, message: 'is a required field'
  validates_presence_of :rank_class, message: 'is a required field'

  validate :validate_rank_class_class,
    :validate_same_nomenclatural_code,
    :validate_parent_rank_is_higher,
    :validate_child_rank_is_equal_or_lower,
    :check_new_rank_class,
    :check_new_parent_class,
    :validate_source_type,
    :new_parent_taxon_name,
    :name_is_latinized,
    :name_is_valid_format,
    :verbatim_author_without_digits,
    :verbatim_author_with_closed_parens_when_present

  after_create :create_otu, if: -> {self.also_create_otu}

  has_one :type_taxon_name_relationship, -> {
    where("taxon_name_relationships.type LIKE 'TaxonNameRelationship::Typification::%'")
  }, class_name: 'TaxonNameRelationship', foreign_key: :object_taxon_name_id

  has_one :type_taxon_name, through: :type_taxon_name_relationship, source: :subject_taxon_name

  has_one :latinized_taxon_name_classification, -> {
    where("taxon_name_classifications.type LIKE 'TaxonNameClassification::Latinized::%'")
  }, class_name: 'TaxonNameClassification', foreign_key: :taxon_name_id

  has_many :type_of_relationships, -> {
    where("taxon_name_relationships.type LIKE 'TaxonNameRelationship::Typification::%'")
  }, class_name: 'TaxonNameRelationship', foreign_key: :subject_taxon_name_id

  has_many :type_of_taxon_names, through: :type_of_relationships, source: :object_taxon_name

  has_many :original_combination_relationships, -> {
    where("taxon_name_relationships.type LIKE 'TaxonNameRelationship::OriginalCombination::%'")
  }, class_name: 'TaxonNameRelationship', foreign_key: :object_taxon_name_id

  has_many :combination_relationships, -> {
    where("taxon_name_relationships.type LIKE 'TaxonNameRelationship::Combination::%'")
  }, class_name: 'TaxonNameRelationship', foreign_key: :subject_taxon_name_id

  has_many :combinations, through: :combination_relationships, source: :object_taxon_name

  has_many :type_materials, class_name: 'TypeMaterial', inverse_of: :protonym

  TaxonNameRelationship.descendants.each do |d|
    if d.respond_to?(:assignment_method)
      if d.name.to_s =~ /TaxonNameRelationship::(Iczn|Icn|Icvcn|Icnp|SourceClassifiedAs)/
        relationship = "#{d.assignment_method}_relationship".to_sym
        has_one relationship, class_name: d.name.to_s, foreign_key: :subject_taxon_name_id
        has_one d.assignment_method.to_sym, through: relationship, source: :object_taxon_name
      end

      if d.name.to_s =~ /TaxonNameRelationship::(OriginalCombination|Typification)/
        relationships = "#{d.assignment_method}_relationships".to_sym
        # ActiveRecord::Base.send(:sanitize_sql_array, [d.name])
        has_many relationships, -> {
          where('taxon_name_relationships.type LIKE ?', d.name + '%')
        }, class_name: 'TaxonNameRelationship', foreign_key: :subject_taxon_name_id
        has_many d.assignment_method.to_s.pluralize.to_sym, through: relationships, source: :object_taxon_name
      end
    end

    if d.respond_to?(:inverse_assignment_method)
      if d.name.to_s =~ /TaxonNameRelationship::(Iczn|Icn|Icnp|Icvcn|SourceClassifiedAs)/
        relationships = "#{d.inverse_assignment_method}_relationships".to_sym
        # ActiveRecord::Base.send(:sanitize_sql_array, [d.name])
        has_many relationships, -> {
          where('taxon_name_relationships.type LIKE ?', d.name + '%')
        }, class_name: 'TaxonNameRelationship', foreign_key: :object_taxon_name_id
        has_many d.inverse_assignment_method.to_s.pluralize.to_sym, through: relationships, source: :subject_taxon_name
      end

      if d.name.to_s =~ /TaxonNameRelationship::(OriginalCombination|Typification)/ # |SourceClassifiedAs
        relationship = "#{d.inverse_assignment_method}_relationship".to_sym
        has_one relationship, class_name: d.name.to_s, foreign_key: :object_taxon_name_id
        has_one d.inverse_assignment_method.to_sym, through: relationship, source: :subject_taxon_name
      end
    end
  end

  # TODO: remove
  scope :named, -> (name) {where(name:)}

  scope :with_name_in_array, -> (array) {where(name: array) }

  # find classifications for taxon
  scope :with_taxon_name_classifications_on_taxon_name, -> (t) { includes(:taxon_name_classifications).where('taxon_name_classifications.taxon_name_id = ?', t).references(:taxon_name_classifications) }

  # find taxa with classifications
  scope :with_taxon_name_classifications, -> { joins(:taxon_name_classifications) }
  scope :with_taxon_name_classification, -> (taxon_name_class_name) { includes(:taxon_name_classifications).where('taxon_name_classifications.type = ?', taxon_name_class_name).references(:taxon_name_classifications) }
  scope :with_taxon_name_classification_base, -> (taxon_name_class_name_base) { includes(:taxon_name_classifications).where('taxon_name_classifications.type LIKE ?', "#{taxon_name_class_name_base}%").references(:taxon_name_classifications) }
  scope :with_taxon_name_classification_containing, -> (taxon_name_class_name_fragment) { includes(:taxon_name_classifications).where('taxon_name_classifications.type LIKE ?', "%#{taxon_name_class_name_fragment}%").references(:taxon_name_classifications) }
  scope :with_taxon_name_classification_array, -> (taxon_name_class_name_base_array) { includes(:taxon_name_classifications).where('taxon_name_classifications.type in (?)', taxon_name_class_name_base_array).references(:taxon_name_classifications) }
  scope :without_taxon_name_classification, -> (taxon_name_class_name) { where('"taxon_names"."id" not in (SELECT taxon_name_id FROM taxon_name_classifications WHERE type LIKE ?)', "#{taxon_name_class_name}")}
  scope :without_taxon_name_classification_array, -> (taxon_name_class_name_array) { where('"taxon_names"."id" not in (SELECT taxon_name_id FROM taxon_name_classifications WHERE type in (?))', taxon_name_class_name_array) }
  scope :without_taxon_name_classifications, -> { includes(:taxon_name_classifications).where(taxon_name_classifications: {taxon_name_id: nil}) }
  scope :with_type_material_array, ->  (type_material_array) { joins('LEFT OUTER JOIN "type_materials" ON "type_materials"."protonym_id" = "taxon_names"."id"').where("type_materials.collection_object_id in (?) AND type_materials.type_type in ('holotype', 'neotype', 'lectotype', 'syntype', 'syntypes')", type_material_array) }
  scope :with_type_of_taxon_names, -> (type_id) { includes(:related_taxon_name_relationships).where("taxon_name_relationships.type LIKE 'TaxonNameRelationship::Typification%' AND taxon_name_relationships.subject_taxon_name_id = ?", type_id).references(:related_taxon_name_relationships) }
  scope :with_homonym_or_suppressed, -> { includes(:taxon_name_relationships).where("taxon_name_relationships.type LIKE 'TaxonNameRelationship::Iczn::Invalidating::Homonym%' OR taxon_name_relationships.type LIKE 'TaxonNameRelationship::Iczn::Invalidating::Suppression::Total'").references(:taxon_name_relationships) }
  scope :without_homonym_or_suppressed, -> { where("id not in (SELECT subject_taxon_name_id FROM taxon_name_relationships WHERE taxon_name_relationships.type LIKE 'TaxonNameRelationship::Iczn::Invalidating::Homonym%' OR taxon_name_relationships.type LIKE 'TaxonNameRelationship::Iczn::Invalidating::Usage%' OR taxon_name_relationships.type LIKE 'TaxonNameRelationship::Iczn::Invalidating::Misapplication' OR taxon_name_relationships.type LIKE 'TaxonNameRelationship::Iczn::Invalidating::Suppression::Total')") }
  scope :with_primary_homonym, -> (primary_homonym) {where(cached_primary_homonym: primary_homonym)}
  scope :with_primary_homonym_alternative_spelling, -> (primary_homonym_alternative_spelling) {where(cached_primary_homonym_alternative_spelling: primary_homonym_alternative_spelling)}
  scope :with_secondary_homonym, -> (secondary_homonym) {where(cached_secondary_homonym: secondary_homonym)}
  scope :with_secondary_homonym_alternative_spelling, -> (secondary_homonym_alternative_spelling) {where(cached_secondary_homonym_alternative_spelling: secondary_homonym_alternative_spelling)}

  # TODO, move to IsData or IsProjectData
  scope :with_project, -> (project_id) {where(project_id:)}

  scope :is_species_group, -> { where("rank_class ILIKE '%speciesgroup%'") }
  scope :is_genus_group, -> { where("rank_class ILIKE '%genusgroup%'") }
  scope :is_family_group, -> { where("rank_class ILIKE '%family%'") }

  scope :is_species_or_genus_group, -> {  where("rank_class ILIKE '%speciesgroup%' OR rank_class ILIKE '%genusgroup%'")   }

  scope :is_original_name, -> { where("cached_author_year NOT ILIKE '(%'") }
  scope :is_not_original_name, -> { where("cached_author_year ILIKE '(%'") }

  # Deprecated, moved to TaxonName.  Combinations are rankless, but we need generic sort at TaxonName level
  # Protonym.order_by_rank(RANKS) or Protonym.order_by_rank(ICZN)
  # scope :order_by_rank, -> (code) {order(Arel.sql("position(taxon_names.rank_class in '#{code}')"))}

  # @return [Protonym]
  #   a name ready to become the root
  def self.stub_root(project_id: nil, by: nil)
    Protonym.new(name: 'Root', rank_class: 'NomenclaturalRank', parent_id: nil, project_id:, by:)
  end

  def self.family_group_base(name_string)
    name_string.match(/(^.*)(ini|ina|inae|idae|oidae|odd|ad|oidea)$/)
    $1 || name_string
  end

  def self.family_group_name_at_rank(name_string, rank_string)
    if name_string == Protonym.family_group_base(name_string)
      name_string
    else
      Protonym.family_group_base(name_string) + Ranks.lookup(:iczn, rank_string).constantize.try(:valid_name_ending).to_s
    end
  end

  # @param rank full String to match rank_class, like '%genusgroup%' or '%::Family'
  #    scope to names used in taxon determinations
  #  !! Ensure collection_object_query is scoped to project
  def self.names_at_rank_group_for_collection_objects(rank: nil, collection_object_query: nil)

    # Find all the names for the objects in question
    names = ::Queries::TaxonName::Filter.new(collection_object_query:).all

    s = 'WITH q_co_names AS (' + names.distinct.all.to_sql + ') ' +
      ::Protonym
      .joins('JOIN taxon_name_hierarchies tnh on tnh.ancestor_id = taxon_names.id')
      .joins('JOIN q_co_names as q_co1 on q_co1.id = tnh.descendant_id')
      .where('taxon_names.rank_class ilike ?', rank)
      .to_sql

    ::Protonym.from('(' + s + ') as taxon_names').distinct
  end

  # A convenience method to make this
  # name a low-level synonym of another.
  # Presently limited in scope to names that share rank (not rank group)
  def synonymize_with(protonym)
    return false if protonym.nil?
    return false if protonym.rank_class.to_s != rank_class.to_s

    begin
      case nomenclatural_code
      when  :iczn
        TaxonNameRelationship::Iczn::Invalidating::Synonym.create!(subject_taxon_name: self, object_taxon_name: protonym)
      when :icn
        TaxonNameRelationship::Icn::Unaccepting::Synonym.create!(subject_taxon_name: self, object_taxon_name: protonym)
      when :icnp
        TaxonNameRelationship::Icnp::Unaccepting::Synonym.create!(subject_taxon_name: self, object_taxon_name: protonym)
      when :icvnc
        TaxonNameRelationship::Icnp::Unaccepting::SupressedSynony.create!(subject_taxon_name: self, object_taxon_name: protonym)
      else
        return false
      end
    rescue ActiveRecord::RecordInvalid
      return false
    end

  end


  # @return [Array of Strings]
  #   genera where the species was placed
  def all_generic_placements
    valid_name = get_valid_taxon_name
    return nil unless valid_name.rank_string !=~/Species/
    descendants_and_self = valid_name.descendants + [self] + self.combinations
    relationships = TaxonNameRelationship.where_object_in_taxon_names(descendants_and_self).with_two_type_bases('TaxonNameRelationship::OriginalCombination::OriginalGenus', 'TaxonNameRelationship::Combination::Genus')
    (relationships.collect { |r| r.subject_taxon_name.name } + [self.ancestor_at_rank('genus').try(:name)]).uniq
  end

  def list_of_coordinated_names
    list = []
    if self.rank_string
      r = self.iczn_set_as_incorrect_original_spelling_of_relationship
      if r.blank?
        search_rank = NomenclaturalRank::Iczn.group_base(rank_string)
        if !!search_rank
          if search_rank =~ /Family/
            if self.cached_is_valid
              z = Protonym.family_group_base(self.name)
              search_name = z.nil? ? nil : Protonym::FAMILY_GROUP_ENDINGS.collect{|i| z+i}
            else
              search_name = nil
            end
          else
            search_name = self.name
          end
        else
          search_name = nil
        end

        #  r = TaxonNameRelationship.where_subject_is_taxon_name(self).with_type_array(TAXON_NAME_RELATIONSHIP_NAMES_MISSPELLING)
        #  if !search_name.nil? && r.empty?
        if !search_name.nil? && is_available?
          list = Protonym
            .ancestors_and_descendants_of(self)
            .with_rank_class_including(search_rank)
            .where(name: search_name)
            .not_self(self)
            .that_is_valid
        else
          list = []
        end
      else
        list = [r.object_taxon_name]
      end
    end
    return list
  end

  def is_available?(refresh = false)
    if !refresh
      @is_available ||= !has_misspelling_or_misapplication_relationship? && !classification_unavailable?
    else
      @is_available = !has_misspelling_or_misapplication_relationship? && !classification_unavailable?
    end
    @is_available
  end

  # @return [Protonym]
  #   the accepted "valid" version of this name in the present classification
  def get_valid_taxon_name
    v = first_possible_valid_taxon_name
    if v == self
      self
    elsif v.cached_valid_taxon_name_id == v.id
      v
    elsif !v.cached_valid_taxon_name_id.nil?
      v.valid_taxon_name
    else
      self
    end
  end

  # This method is currently only used for setting cached_primary_homonym
  # @return [nil, false, String]
  #   !! Why both?
  def get_genus_species(genus_option, self_option)
    return nil if rank_class.nil?
    genus = nil
    name1 = nil

    if is_species_rank? # self.rank_string =~ /Species/
      if genus_option == :original
        genus = original_genus
      elsif genus_option == :current
        genus = ancestor_at_rank('genus')
      else
        return false
      end

      genus = genus.name if genus.present?
      return nil if genus.blank?
    end

    if self_option == :self
      name1 = name
    elsif self_option == :alternative
      name1 = name_with_alternative_spelling
    end

    return nil if genus.nil? && name1.nil? # <- hitting this because Genus is never set
    [genus, name1].compact.join(' ')
    # (genus.to_s + ' ' + name1.to_s).squish
  end

  # TODO, make back half of this raw SQL
  def lowest_rank_coordinated_taxon
    list = [self] + list_of_coordinated_names
    if list.count == 1
      self
    else
      parents = list.collect{|i| i.parent.id}
      list.detect{|t| !parents.include?(t.id)}
    end
  end

  # @return [Array]
  #    all descendant and ancestor protonyms for this Protonym
  def ancestors_and_descendants
    Protonym.ancestors_and_descendants_of(self).not_self(self).to_a
  end

  # @return [ TypeMaterial, [] ]  ?!
  def get_primary_type
    return [] unless self.rank_class.parent.to_s =~ /Species/
    s = self.type_materials.syntypes
    p = self.type_materials.primary
    if s.empty? && p.count == 1
      p
    elsif p.empty? && !s.empty?
      s
    else
      []
    end
  end

  ## taxon_name.predicted_children_rank('Cicadellidae') >> NomenclaturalRank::Iczn::FamilyGroup::Family
  def predicted_child_rank(child_string)
    return nil if child_string.blank?
    parent_rank = rank_class.to_s
    parent_rank_name = rank_name
    ncode = nomenclatural_code

    return nil if ncode.nil? # Happens with some names like "Root"

    if child_string == child_string.downcase
      if !is_species_rank?
        r = Ranks.lookup(ncode, 'species')
      elsif parent_rank_name == 'species'
        r = Ranks.lookup(ncode, 'subspecies')
      elsif parent_rank_name == 'subspecies'
        r = Ranks.lookup(ncode, 'variety')
      elsif parent_rank_name == 'variety'
        r = Ranks.lookup(ncode, 'form')
      elsif parent_rank_name == 'form'
        r = Ranks.lookup(ncode, 'subform')
      else
        return nil
      end
    elsif child_string == child_string.capitalize
      if rank_name == 'genus'
        r = Ranks.lookup(ncode, 'subgenus')
      else
        Ranks.lookup(ncode, 'family').constantize.valid_parents.each do |r1|
          r2 = r1.constantize
          if r2.valid_name_ending.present? && child_string.end_with?(r2.valid_name_ending) && r2.typical_use && RANKS.index(r1) > RANKS.index(parent_rank)
            r = r1
            break
          end
        end
        r = Ranks.lookup(ncode, 'genus') if r.nil?
      end
    else
      return nil
    end
    return nil if r.nil?
    r.constantize
  end

  # temporary method to get a number of taxa described by year
  def number_of_taxa_by_year
    file_name = '/tmp/taxa_by_year' + '_' + Time.now.to_i.to_s + '.csv'
    a = {}
    descendants.find_each do |z|
      year = z.year_integer
      year = 0 if year.nil?
      a[year] = {valid: 0, synonyms: 0} unless a[year]
      if z.rank_string == 'NomenclaturalRank::Iczn::SpeciesGroup::Species'
        if z.cached_is_valid
          a[year][:valid] = a[year][:valid] += 1
        elsif TaxonNameRelationship.where_subject_is_taxon_name(z.id).with_type_array(::TAXON_NAME_RELATIONSHIP_NAMES_SYNONYM).any?
          a[year][:synonyms] = a[year][:synonyms] += 1
        end
      end
    end
    for i in 1758..Time.now.year do
      a[i] = {valid: 0, synonyms: 0} unless a[i]
    end
    b = a.sort.to_h
    CSV.open(file_name, 'w') do |csv|
      #CSV.generate do |csv|
      csv << ['year', 'valid species', 'synonyms']
      b.keys.each do |i|
        csv << [i, b[i][:valid], b[i][:synonyms]]
      end
    end
  end

  # !! TODO: Should not be possible- fix the incoming data
  # @return [Boolean]
  #    true if taxon2 has the same primary type
  def has_same_primary_type(taxon2)
    return true unless rank_class.parent.to_s =~ /Species/

    taxon1_types = get_primary_type.sort_by{|i| i.id}
    taxon2_types = taxon2.get_primary_type.sort_by{|i| i.id}
    return true if taxon1_types.empty? && taxon2_types.empty? # both are empty !! If they are both empty then they don't have the same type, the have no types  !!
    return false if taxon1_types.empty? || taxon2_types.empty? # one is empty

    taxon1_types.map(&:collection_object_id) == taxon2_types.map(&:collection_object_id) # collect{|i| i.collection_object_id}
  end

  # return [Array]
  #  TaxonNameRelationship classes that are applicable to this name, as deterimined by Rank
  def original_combination_class_relationships
    relations = []
    TaxonNameRelationship::OriginalCombination.descendants.each do |r|
      relations.push(r) if r.valid_object_ranks.include?(self.rank_string)
    end
    relations
  end

  # @return [Array]
  #   A relationships for each possible original combination relationship
  def original_combination_relationships_and_stubs
    # TODO: figure out where to really put this, likely in one big sort
    display_order = [ :original_genus, :original_subgenus, :original_species, :original_subspecies, :original_variety, :original_subvariety, :original_form, :original_subform ]

    defined_relations = self.original_combination_relationships.all
    created_already   = defined_relations.collect{|a| a.class}
    new_relations     = []

    original_combination_class_relationships.each do |r|
      new_relations.push( r.new(object_taxon_name: self) ) if !created_already.include?(r)
    end

    (new_relations + defined_relations).sort{|a,b|
      display_order.index(a.class.inverse_assignment_method) <=> display_order.index(b.class.inverse_assignment_method)
    }
  end

  # @return [Boolean]
  #   whether this name has one of the TaxonNameClassifications that except it from being tested as latinized
  def has_latinized_exceptions?
    # The second half of this handles classifications in memory, as required to save a non-latinized name (i.e. don't tune it to .any?)
    # !((type == 'Protonym') && (taxon_name_classifications.collect{|t| t.type} & EXCEPTED_FORM_TAXON_NAME_CLASSIFICATIONS).empty?)

    # Is faster than above?
    #    return true if rank_string =~ /Icnp/ && (name.start_with?('Candidatus ') || name.start_with?('Ca. '))

    return true if is_family_rank? && !family_group_name_form_relationship.nil?
    return true if is_family_rank? && !(taxon_name_relationships.collect{|i| i.type} & ::TAXON_NAME_RELATIONSHIP_NAMES_SYNONYM).empty?
    return true unless (taxon_name_classifications.collect{|i| i.type} & ::EXCEPTED_FORM_TAXON_NAME_CLASSIFICATIONS).empty?
    return true unless (taxon_name_relationships.collect{|i| i.type} & ::TAXON_NAME_RELATIONSHIP_NAMES_MISSPELLING).empty?
    false
  end

  def is_latin?
    !NOT_LATIN.match(name) || has_latinized_exceptions? || rank_string =~ /Icvcn::Species/
  end

  # @return [Boolean]
  #   whether this name has one of the TaxonNameRelationships which justify wrong form of the name
  def has_misspelling_relationship?
    taxon_name_relationships.with_type_array(TAXON_NAME_RELATIONSHIP_NAMES_MISSPELLING).any?
  end

  # @return [Boolean]
  def has_misspelling_or_misapplication_relationship?
    taxon_name_relationships.with_type_array(TAXON_NAME_RELATIONSHIP_NAMES_MISSPELLING_AND_MISAPPLICATION).any?
  end

  # Same as is_original_name?!
  def has_alternate_original?
    cached_original_combination && (cached != cached_original_combination) ? true : false
  end

  def is_species_rank?
    SPECIES_RANK_NAMES.include?(rank_string)
  end

  def is_genus_rank?
    GENUS_RANK_NAMES.include?(rank_string)
  end

  def is_genus_or_species_rank?
    GENUS_AND_SPECIES_RANK_NAMES.include?(rank_string)
  end

  def is_family_or_genus_or_species_rank?
    FAMILY_AND_GENUS_AND_SPECIES_RANK_NAMES.include?(rank_string)
  end

  def is_family_rank?
    FAMILY_RANK_NAMES.include?(rank_string)
  end

  def is_higher_rank?
    HIGHER_RANK_NAMES.include?(rank_string)
  end

  # @return Boolean
  def is_original_name?
    cached_author_year =~ /\(/ ? false : true
  end

  def reduce_list_of_synonyms(list)
    return [] if list.empty?
    list1 = list.select{|s| s.id == s.lowest_rank_coordinated_taxon.id}
    list1.reject!{|s| self.cached_valid_taxon_name_id == s.cached_valid_taxon_name_id} unless list1.empty?
    unless list1.empty?
      date1 = self.cached_nomenclature_date
      unless date1.nil?
        list1.reject!{|s| date1 < (s.cached_nomenclature_date ? s.cached_nomenclature_date : Time.utc(1))}
      end
    end
    list1
  end

  def name_with_alternative_spelling
    if rank_class.nil? || nomenclatural_code != :iczn
      # rank_string =~ /::Icn::/ # self.class != Protonym
      return nil
    elsif is_species_rank? # rank_string =~ /Species/
      n = name.squish # remove extra spaces and line brakes
      n = n.split(' ').last
      n = n[0..-4] + 'ae' if n =~ /^[a-z]*iae$/ # -iae > -ae in the end of word
      n = n[0..-6] + 'orum' if n =~ /^[a-z]*iorum$/ # -iorum > -orum
      n = n[0..-6] + 'arum' if n =~ /^[a-z]*iarum$/ # -iarum > -arum
      n = n[0..-3] + 'a' if n =~ /^[a-z]*um$/ # -um > -a
      n = n[0..-3] + 'a' if n =~ /^[a-z]*us$/ # -us > -a
      n = n[0..-3] + 'e' if n =~ /^[a-z]*is$/ # -is > -e
      n = n[0..-3] + 'ra' if n =~ /^[a-z]*er$/ # -er > -ra
      n = n[0..-7] + 'ensis' if n =~ /^[a-z]*iensis$/ # -iensis > -ensis
      n = n[0..-5] + 'ana' if n =~ /^[a-z]*iana$/ # -iana > -ana
      n = n.gsub('ae', 'e') if n =~ /^[a-z]*ae[a-z]+$/ # -ae-
      n = n.gsub('oe', 'e').
        gsub('ai', 'i').
        gsub('ei', 'i').
        gsub('ej', 'i').
        gsub('ii', 'i').
        gsub('ij', 'i').
        gsub('jj', 'i').
        gsub('j', 'i').
        gsub('y', 'i').
        gsub('v', 'u').
        gsub('rh', 'r').
        gsub('th', 't').
        gsub('k', 'c').
        gsub('ch', 'c').
        gsub('tt', 't').
        gsub('bb', 'b').
        gsub('rr', 'r').
        gsub('nn', 'n').
        gsub('mm', 'm').
        gsub('pp', 'p').
        gsub('ss', 's').
        gsub('ff', 'f').
        gsub('ll', 'l').
        gsub('ct', 't').
        gsub('ph', 'f').
        gsub('-', '')
      n = n[0, 3] + n[3..-4].gsub('o', 'i') + n[-3, 3] if n.length > 6 # connecting vowel in the middle of the word (nigrocinctus vs. nigricinctus)
    elsif rank_string =~ /Family/
      n_base = Protonym.family_group_base(self.name)
      if n_base.nil? || n_base == self.name
        n = self.name
      else
        n = n_base + 'idae'
      end
    else
      n = self.name.squish
    end

    return n
  end

  def predict_three_forms
    exception = LATIN_ADJECTIVES[name]

    return exception unless exception.nil?
    m_name, f_name, n_name = nil, nil, nil
    case name
    when /(color|coloris)$/
      m_name, f_name, n_name = name, name, name
    when /is$/
      m_name, f_name, n_name = name, name, name[0..-3] + 'e'
    when /e$/
      m_name, f_name, n_name = name[0..-2] + 'is', name[0..-2] + 'is', name
    when /us$/
      m_name, f_name, n_name = name, name[0..-3] + 'a', name[0..-3] + 'um'
    when /(niger|integer)$/
      m_name, f_name, n_name = name, name[0..-3] + 'ra', name[0..-3] + 'rum'
    when /(fer|ger)$/
      m_name, f_name, n_name = name, name + 'a', name + 'um'
    when /er$/
      m_name, f_name, n_name = name, name[0..-3] + 'ra', name[0..-3] + 'rum'
    when /(ferum|gerum)$/
      m_name, f_name, n_name = name[0..-3], name[0..-3] + 'a', name
    when /(gera|fera)$/
      m_name, f_name, n_name = name[0..-2], name, name[0..-2] + 'um'
    when /(brum|frum|grum)$/
      m_name, f_name, n_name = name[0..-4] + 'er', name[0..-3] + 'a', name
    when /(bra|gra|fra)$/
      m_name, f_name, n_name = name[0..-3] + 'er', name, name[0..-2] + 'um'
    when /(um)$/
      m_name, f_name, n_name = name[0..-3] + 'us', name[0..-3] + 'a', name
    when /a$/
      m_name, f_name, n_name = name[0..-2] + 'us', name, name[0..-2] + 'um'
    when /(nor|ior|jor)$/
      m_name, f_name, n_name = name, name, name[0..-3] + 'us'
    else
      m_name, f_name, n_name = name, name, name
    end
    {masculine_name: m_name, feminine_name: f_name, neuter_name: n_name}
  end

  def genus_suggested_gender
    return nil unless rank_string =~/Genus/
    TAXON_NAME_CLASSIFICATION_GENDER_CLASSES.each do |g|
      g.possible_genus_endings.each do |e|
        return g.name.demodulize.underscore.humanize.downcase if self.name =~ /^[a-zA-Z]*#{e}$/
      end
    end
    nil
  end

  def species_questionable_ending(taxon_name_classification_class, tested_name)
    return nil unless is_species_rank?
    taxon_name_classification_class.questionable_species_endings.each do |e|
      return e if tested_name =~ /^[a-z]*#{e}$/
    end
    nil
  end

  # TODO: likley belongs in lib/vendor/biodiversity.rb
  # @return [Boolean]
  #   Wraps set_original_combination with result from Biodiversity parse
  #   !!You must can optionally pre-calculate a disambiguated protonym if you wish to use one.
  # @param biodiversity_result [ Biodiversity.result ]
  # @param relationship_housekeeping [Hash] like `{project_id: 22, created_by_id: 2}`
  def build_original_combination_from_biodiversity(biodiversity_result, relationship_housekeeping = {})
    br = biodiversity_result
    return false if br.nil?
    c = [br.disambiguated_combination, br.combination].first
    build_original_combinations(c, relationship_housekeeping)
    true
  end

  # @return [Boolean]
  # @param combination [Combination]
  # @param relationship_housekeeping [Hash] like `{project_id: 22, created_by_id: 2}`
  #   builds, but does not save, original relationships for all corresponding protonyms in a combination
  #   !! Replaces existing relationship without checking identify if they are there!
  def build_original_combinations(combination, relationship_housekeeping)
    return false if combination.nil?
    combination.protonyms_by_rank.each do |rank, p|
      send("original_#{rank}=", p)
    end

    if !relationship_housekeeping.empty?
      combination.protonyms_by_rank.each do |rank, p|
        r = send("original_#{rank}_relationship")
        r.write_attributes(relationship_housekeeping)
      end
    end
    true
  end

  def get_original_combination
    return verbatim_name if !GENUS_AND_SPECIES_RANK_NAMES.include?(rank_string) && !verbatim_name.nil?
    e = original_combination_elements
    return nil if e.none?

    # Weird, why?
    # DD: in ICVCN the species name is "Potato spindle tuber viroid", the genus name is only used for classification...
    #
    # @proceps: then we should exclude or alter elements before we get to this point, not here, so that the renderer still works, exceptions at this point are bad
    # and this didn't do what you think it did, it's was returning an Array of two things
    return e[:species][1] if rank_class.to_s =~ /Icvcn/

    p = TaxonName::COMBINATION_ELEMENTS.inject([]){|ary, r| ary.push(e[r]) }

    s = p.flatten.compact.join(' ')
    (s.presence)
  end

  # @return [Hash]
  #
  # {
  #  genus: ["", 'Aus' ],
  #  ...
  #  form: ['frm', 'aus']
  # }
  #
  def original_combination_elements
    elements = { }
    return elements if rank.blank?

    this_rank = rank.to_sym

    # Why this?
    #   We need to apply gender to "internal" names for original combinations, everything
    #   but the last name
    # DD: if we have subspecies, the species name should be used not in the original form,
    # but the form correlated with the present genus gender
    # TODO: get SQL based ordering for original_combination_relationships, hard coded

    # order the relationships
    r = original_combination_relationships.reload.sort{|a,b| ORIGINAL_COMBINATION_RANKS.index(a.type) <=> ORIGINAL_COMBINATION_RANKS.index(b.type) }

    # get gender from first
    gender = original_genus&.gender_name # r.first.subject_taxon_name.gender_name

    # Apply gender to everything but the last
    total = r.count - 1
    r.each_with_index do |j, i|
      if j.type =~ /enus/ || i == total
        g = nil
      else
        g = gender
      end
      elements.merge! j.combination_name(g) # this is like '{genus: [nil, 'Aus']}
    end

    # what is point of this? Do we get around this check by requiring self relationships? (species aus has species relationship to self)
    # DD: we do not require it, it is optional
    if !r.empty? && r.collect{|i| i.subject_taxon_name}.last.lowest_rank_coordinated_taxon.id != lowest_rank_coordinated_taxon.id
      if elements[this_rank].nil?
        elements[this_rank] = [nil, original_name]
      end
    end

    if elements.any?
      if !elements[:genus] && !not_binominal?
        if original_genus
          elements[:genus] = [nil, "[#{original_genus&.name}]"]
        else
          elements[:genus] = [nil, '[GENUS NOT SPECIFIED]']
        end
      end
      # If there is no :species, but some species group, add element
      elements[:species] = [nil, '[SPECIES NOT SPECIFIED]'] if !elements[:species] && ( [:subspecies, :variety, :form] & elements.keys ).size > 0
    end

    elements
  end

  # @return [[rank_name, name], nil]
  #   Used in ColDP export
  def original_combination_infraspecific_element(elements = nil, remove_sic = false)
    elements ||= original_combination_elements

    elements = elements.each { |r, e| e.delete('[sic]') } if remove_sic

    # TODO: consider plants/other codes?
    [:form, :variety, :subspecies].each do |r|
      return [r.to_s, elements[r].last] if elements[r]
    end
    nil
  end

  # @return [String, nil]
  #    a monominal, as originally rendered, with parens if subgenus
  def original_name
    n = verbatim_name.nil? ? name_with_misspelling(nil) : verbatim_name
    n = "(#{n})" if n && rank_name == 'subgenus'
    n
  end

  def get_original_combination_html
    return  "\"<i>Candidatus</i> #{get_original_combination}\"" if is_candidatus?
    v = get_original_combination
    if v.present? && is_hybrid?
      w = v.split(' ')
      w[-1] = ('×' + w[-1]).gsub('×(', '(×').gsub(') [sic]', ' [sic])').gsub(') (sic)', ' (sic))')
      v = w.join(' ')
    end
    v = v.gsub(') [sic]', ' [sic])').gsub(') (sic)', ' (sic))') if v.present?

    v = Utilities::Italicize.taxon_name(v) if is_genus_or_species_rank?
    v = '† ' + v if v.present? && is_fossil?
    v
  end

  def update_cached_original_combinations
    update_columns(
      cached_original_combination: get_original_combination,
      cached_original_combination_html: get_original_combination_html,
      cached_primary_homonym: get_genus_species(:original, :self),
      cached_primary_homonym_alternative_spelling: get_genus_species(:original, :alternative))
  end

  def set_cached_species_homonym
    update_columns(
      cached_secondary_homonym: get_genus_species(:current, :self),
      cached_secondary_homonym_alternative_spelling: get_genus_species(:current, :alternative)
    )
  end

  def set_cached_names_for_descendants
    dependants = []

    TaxonName.transaction_with_retry do
      if is_genus_or_species_rank?
        dependants = Protonym.unscoped.descendants_of(self).to_a
      end

      dependants.each do |i|
        n = i.get_full_name
        columns_to_update = {
          cached: n,
          cached_html: i.get_full_name_html(n),
          cached_author_year: i.get_author_and_year,
          cached_nomenclature_date: i.nomenclature_date
        }

        if i.is_species_rank?
          columns_to_update[:cached_secondary_homonym] = i.get_genus_species(:current, :self)
          columns_to_update[:cached_secondary_homonym_alternative_spelling] = i.get_genus_species(:current, :alternative)
        end

        i.update_columns(columns_to_update)
      end
    end
  end

  def set_cached_names_for_dependants
    related_through_original_combination_relationships = []
    combination_relationships = []

    TaxonName.transaction_with_retry do
      if is_genus_or_species_rank?
        related_through_original_combination_relationships = TaxonNameRelationship.where_subject_is_taxon_name(self).with_type_contains('OriginalCombination')
        combination_relationships = TaxonNameRelationship.where_subject_is_taxon_name(self).with_type_contains('::Combination')
      end

      classified_as_relationships = TaxonNameRelationship.where_object_is_taxon_name(self).with_type_contains('SourceClassifiedAs')

      related_through_original_combination_relationships.collect{|i| i.object_taxon_name}.uniq.each do |i|
        i.update_cached_original_combinations
      end

      # Update values in Combinations
      combination_relationships.collect{|i| i.object_taxon_name}.uniq.each do |j|
        n = j.get_full_name
        j.update_columns(
          cached: n,
          cached_html: j.get_full_name_html(n),
          cached_author_year: j.get_author_and_year,
          cached_nomenclature_date: j.nomenclature_date)
      end

      classified_as_relationships.collect{|i| i.subject_taxon_name}.uniq.each do |i|
        i.update_column(:cached_classified_as, i.get_cached_classified_as)
      end

      classified_as_relationships.collect{|i| i.object_taxon_name}.uniq.each do |i|
        n = i.get_full_name
        i.update_columns(
          cached: n,
          cached_html: i.get_full_name_html(n),
          cached_author_year: i.get_author_and_year,
          cached_nomenclature_date: i.nomenclature_date)
      end

      misspelling_relationships = TaxonNameRelationship.where_object_is_taxon_name(self).with_type_array(TAXON_NAME_RELATIONSHIP_NAMES_MISSPELLING_AND_MISAPPLICATION)
      misspelling_relationships.collect{|i| i.subject_taxon_name}.uniq.each do |i|
        i.update_columns(cached_author_year: i.get_author_and_year,
                         cached_nomenclature_date: i.nomenclature_date)
      end
    end
  end

  # @return [boolean]
  def nominotypical_sub_of?(protonym)
    is_genus_or_species_rank? && parent == protonym && parent.name == protonym.name
  end

  # @return [Hash]
  def self.batch_move(params)
    return false if params[:parent_id].blank?

    a = Queries::TaxonName::Filter.new(params[:taxon_name_query]).all.where(type: 'Protonym')

    return false if a.count == 0

    moved = []
    unmoved = []

    begin
      a.each do |o|
        if o.update(parent_id: params[:parent_id] )
          moved.push o
        else
          unmoved.push o
        end
      end
    end

    return { moved:, unmoved:}
  end

  # @params params [Hash]
  #   { taxon_name_query: {},
  #     taxon_name_filter_query: {},
  #     async_cutoff: 1
  #   }
  def self.batch_update(params)
    request = QueryBatchRequest.new(
      async_cutoff: params[:async_cutoff] || 50,
      klass: 'TaxonName',
      object_filter_params: params[:taxon_name_query],
      object_params: params[:taxon_name],
      preview: params[:preview],
    )

    query_batch_update(request)
  end

  protected

  def check_new_parent_class
    if is_protonym? && parent_id != parent_id_was && !parent_id_was.nil? && nomenclatural_code == :iczn
      if old_parent = TaxonName.find_by(id: parent_id_was)
        if (rank_name == 'subgenus' || rank_name == 'subspecies') && old_parent.name == name
          errors.add(:parent_id, "The nominotypical #{rank_name} #{name} can not be moved out of the nominal #{old_parent.rank_name}")
        end
      end
    end
  end

  def name_is_latinized
    errors.add(:name, 'Name must be latinized, no digits or spaces allowed') if !is_latin?
  end

  def verbatim_author_without_digits
    errors.add(:verbatim_author, 'Verbatim author may not contain digits, a year may be present') if verbatim_author =~ /\d/
  end

  def verbatim_author_with_closed_parens_when_present
    if verbatim_author.present? and nomenclatural_code != :icn  # workaround until basyonim field exists
      # Regex matches two possible, both params, or no params at start/end
      errors.add(:verbatim_author, 'Verbatim author is missing a parenthesis') unless verbatim_author =~ /\A\([^()]+\)\z|\A[^()]+\z/
    end
  end

  def name_is_valid_format
    rank_class.validate_name_format(self) if name.present? && rank_class && rank_class.respond_to?(:validate_name_format) && !has_latinized_exceptions?
  end

  def create_otu
    Otu.create(by: self.creator, project: self.project, taxon_name_id: self.id)
  end

  def new_parent_taxon_name
    r = self.iczn_uncertain_placement_relationship
    if r.present?
      if self.parent != r.object_taxon_name
        errors.add(:parent_id, "Taxon has an 'Incertae sedis' relationship, which prevent the parent modifications, change the relationship to 'Source classified as' before updating the parent")
      end
    end
  end

  def validate_rank_class_class
    errors.add(:rank_class, 'Rank not found') unless RANKS.include?(rank_string)
  end

  def validate_child_rank_is_equal_or_lower
    if parent && rank_class.present? && rank_string != 'NomenclaturalRank'
      if rank_class_changed?
        a = children.where(type: 'Protonym').pluck(:rank_class)
        v = RANKS.index(rank_string)
        a.each do |b|
          if v >= RANKS.index(b)
            errors.add(:rank_class, "The rank of this taxon (#{rank_name}) should be higher than the ranks of children")
            break
          end
        end
      end
    end
  end

  def validate_parent_rank_is_higher
    if parent && rank_class.present? && rank_string != 'NomenclaturalRank'
      if RANKS.index(rank_string).to_i <= RANKS.index(parent.rank_string).to_i
        errors.add(:parent_id, "The parent rank (#{parent.rank_class.rank_name}) is not higher than the rank (#{rank_name}) of this taxon")
      end
    end
  end

  def validate_same_nomenclatural_code
    if parent&.nomenclatural_code && nomenclatural_code != parent.nomenclatural_code
      errors.add(:rank_class, "The parent nomenclatural code (#{parent.nomenclatural_code.to_s.upcase}) is not matching to the nomenclatural code (#{nomenclatural_code.to_s.upcase}) of this taxon")
    end
  end

  # !! This has to go. A single method is meaningless to the user and to developers.  Which cached is the problem it the problem?
  # We simply can't tell what went wrong in this case.
  #
  # This is a *very* expensive soft validation, it should be fragemented into individual parts likely.
  # It should also not be necessary by default our code should be good enough to handle these
  # issues in the long run.
  # DD: rules for cached tend to evolve, what was good in the past, may not be true today
  # MJY: If the meaning of cached changes then it should be removed, not changed.
  def sv_cached_names # this cannot be moved to soft_validation_extensions
    is_cached = true

  is_cached = false if cached_author_year != get_author_and_year
  is_cached = false if cached_author != get_author

  if is_cached && (
      cached_valid_taxon_name_id != get_valid_taxon_name.id ||
      cached_is_valid != !unavailable_or_invalid? || # Do not change this, we want the calculated value.
      cached_html != get_full_name_html ||
      cached_misspelling != get_cached_misspelling ||
      cached_original_combination != get_original_combination ||
      cached_original_combination_html != get_original_combination_html ||
      cached_primary_homonym != get_genus_species(:original, :self) ||
      cached_nomenclature_date != nomenclature_date ||
      cached_primary_homonym_alternative_spelling != get_genus_species(:original, :alternative) ||
      rank_string =~ /Species/ &&
      (cached_secondary_homonym != get_genus_species(:current, :self) ||
       cached_secondary_homonym_alternative_spelling != get_genus_species(:current, :alternative)))
    is_cached = false
  end

  soft_validations.add(
    :base, 'Cached values should be updated',
    success_message: 'Cached values were updated',
    failure_message:  'Failed to update cached values') if !is_cached
  end

  def set_cached
    old_cached_html = cached_html.to_s
    old_cached_author_year = cached_author_year.to_s

    super
    set_cached_original_combination
    set_cached_original_combination_html
    set_cached_homonymy
    set_cached_species_homonym if is_species_rank?
    set_cached_misspelling
    tn = TaxonName.find(id)
    set_cached_names_for_descendants if tn.cached_html != old_cached_html
    set_cached_names_for_dependants if tn.cached_html.to_s != old_cached_html || tn.cached_author_year.to_s != old_cached_author_year
  end

  def set_cached_homonymy
    update_columns(
      cached_primary_homonym: get_genus_species(:original, :self),
      cached_primary_homonym_alternative_spelling: get_genus_species(:original, :alternative)
    )
  end

  # all three in one update here
  def set_cached_misspelling
    update_column(:cached_misspelling, get_cached_misspelling)
  end

  def set_cached_original_combination
    update_column(:cached_original_combination, get_original_combination)
  end

  def set_cached_original_combination_html
    update_column(:cached_original_combination_html, get_original_combination_html)
  end
  end