Class: CollectingEvent

Overview

A collecting event describes how something (e.g. a CollectionObject) was aquired. It is the unique combination of who, where, when, and how.

Defined Under Namespace

Modules: DwcSerialization, GeoLocate, Georeference

Constant Summary collapse

NEARBY_DISTANCE =
5000
MINIMUM_ELEVATION =
-11000
MAXIMUM_ELEVATION =
8500

Constants included from SoftValidation

SoftValidation::ANCESTORS_WITH_SOFT_VALIDATIONS

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Shared::QueryBatchUpdate

#query_update

Methods included from DwcSerialization

#dwc_georeference_attributes

Methods included from Georeference

#dwc_georeference_source, #georeference_latitude, #georeference_longitude, #get_error_radius, #latitude, #longitude, #next_without_georeference, #verbatim_center_coordinates

Methods included from GeoLocate

#geolocate_attributes, #geolocate_ui_params, #geolocate_ui_params_string, #lat_long_source

Methods included from Shared::IsData

#errors_excepting, #full_error_messages_excepting, #identical, #is_community?, #is_destroyable?, #is_editable?, #is_in_use?, #is_in_users_projects?, #metamorphosize, #similar

Methods included from SoftValidation

#clear_soft_validations, #fix_for, #fix_soft_validations, #soft_fixed?, #soft_valid?, #soft_validate, #soft_validated?, #soft_validations, #soft_validators

Methods included from Shared::HasPapertrail

#attribute_updated, #attribute_updater

Methods included from Shared::ProtocolRelationships

#protocolled?, #reject_protocols

Methods included from Shared::Documentation

#document_array=, #documented?, #reject_documentation, #reject_documents

Methods included from Shared::Confidences

#reject_confidences

Methods included from Shared::Labels

#labeled?

Methods included from Shared::Depictions

#has_depictions?, #image_array=, #reject_depictions, #reject_images

Methods included from Shared::Tags

#reject_tags, #tag_with, #tagged?, #tagged_with?

Methods included from Shared::Notes

#concatenated_notes_string, #reject_notes

Methods included from Shared::Identifiers

#dwc_occurrence_id, #identified?, #next_by_identifier, #previous_by_identifier, #reject_identifiers, #uri, #uuid

Methods included from Shared::DataAttributes

#import_attributes, #internal_attributes, #keyword_value_hash, #reject_data_attributes

Methods included from Shared::Citations

#cited?, #mark_citations_for_destruction, #nomenclature_date, #origin_citation_source_id, #reject_citations, #requires_citation?, #sources_by_topic_id

Methods included from Housekeeping

#has_polymorphic_relationship?

Methods inherited from ApplicationRecord

transaction_with_retry

Instance Attribute Details

#cachedString

A string, typically sliced from verbatim_label, that represents the provided uncertainty value.

Returns:

  • (String)


183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#cached_level0_geographic_nameString?

Returns the auto-calculated level0 (= country in TaxonWorks) value drawn from GeographicNames, never directly user supplied.

Returns:

  • (String, nil)

    the auto-calculated level0 (= country in TaxonWorks) value drawn from GeographicNames, never directly user supplied



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#cached_level1_geographic_nameString?

Returns the auto-calculated level1 (typically state/province) value drawn from GeographicNames, never directly user supplied.

Returns:

  • (String, nil)

    the auto-calculated level1 (typically state/province) value drawn from GeographicNames, never directly user supplied



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#cached_level2_geographic_nameString?

Returns the auto-calculated level2 value (e.g. county) drawn from GeographicNames, never directly user supplied.

Returns:

  • (String, nil)

    the auto-calculated level2 value (e.g. county) drawn from GeographicNames, never directly user supplied



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#document_labelString

A print-ready expanded/clarified version of a verbatim_label intended to clarify interpretation of that label. To be used, for example, when reporting Holotype labels.

Returns:

  • (String)


183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#elevation_precisionString

A float, in meters.

Returns:

  • (String)


183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#end_date_dayInteger

Returns the date of the month the collecting event ended on.

Returns:

  • (Integer)

    the date of the month the collecting event ended on



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#end_date_monthInteger

Returns the month, from 0-12, that the collecting event ended on.

Returns:

  • (Integer)

    the month, from 0-12, that the collecting event ended on



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#end_date_yearInteger

Returns the four digit year, end of the collecting event.

Returns:

  • (Integer)

    the four digit year, end of the collecting event



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#field_notesString

Any/all field notes that this collecting event was derived from, or that supplement this collecting event.

Returns:

  • (String)


183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#formationString?

Returns formation sensu PBDB.

Returns:

  • (String, nil)

    formation sensu PBDB



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#geographic_area_idInteger

Returns the finest geo-political unit that this collecting event can be localized to, can be used for gross georeferencing when Georeference not available.

Returns:

  • (Integer)

    the finest geo-political unit that this collecting event can be localized to, can be used for gross georeferencing when Georeference not available



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#geographic_namesObject

getter for attr :geographic_names



222
223
224
# File 'app/models/collecting_event.rb', line 222

def geographic_names
  @geographic_names
end

#groupString?

Returns member sensu PBDB.

Returns:

  • (String, nil)

    member sensu PBDB



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#lithologyString?

Returns lithology sensu PBDB.

Returns:

  • (String, nil)

    lithology sensu PBDB



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#max_maDecimal?

Returns max_ma (million years) sensu PBDB.

Returns:

  • (Decimal, nil)

    max_ma (million years) sensu PBDB



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#maximum_elevationString

A float, in meters.

Returns:

  • (String)


183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#md5_of_verbatim_labelString

Returns application defined, an index to the verbatim label.

Returns:

  • (String)

    application defined, an index to the verbatim label



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#memberString?

Returns member sensu PBDB.

Returns:

  • (String, nil)

    member sensu PBDB



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#meta_prioritize_geographic_areaBoolean?

Returns A meta attribute. When true then DwcOccurence indexing will use the geographic name hierarchy from GeographicArea, not as inferred by geospatial (Georeference) calculation. The is used when reverse georefencing fails because of the lack of precision of the GeographicItem (shapes) for GeographicAreas.

Returns:

  • (Boolean, nil)

    A meta attribute. When true then DwcOccurence indexing will use the geographic name hierarchy from GeographicArea, not as inferred by geospatial (Georeference) calculation. The is used when reverse georefencing fails because of the lack of precision of the GeographicItem (shapes) for GeographicAreas.



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#min_maDecimal?

Returns min_ma (million years) sensu PBDB.

Returns:

  • (Decimal, nil)

    min_ma (million years) sensu PBDB



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#minimum_elevationString

A float, in meters.

Returns:

  • (String)


183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#no_cachedBoolean

Returns When true, cached values are not built.

Returns:

  • (Boolean)

    When true, cached values are not built



218
219
220
# File 'app/models/collecting_event.rb', line 218

def no_cached
  @no_cached
end

A print-formatted ready representation of this collecting event. !! Do not assume that this remains static, it can change over time with user needs.

Returns:

  • (String)


183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#project_idInteger

the project ID

Returns:

  • (Integer)


183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#start_date_dayInteger

Returns the day of the month the collecting event started on.

Returns:

  • (Integer)

    the day of the month the collecting event started on



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#start_date_monthInteger

Returns the month, from 0-12, that the collecting event started on.

Returns:

  • (Integer)

    the month, from 0-12, that the collecting event started on



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#start_date_yearInteger

Returns the four digit year, start of the collecting event.

Returns:

  • (Integer)

    the four digit year, start of the collecting event



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#time_end_hourInteger

Returns 0-23.

Returns:

  • (Integer)

    0-23



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#time_end_minuteInteger

Returns 0-59.

Returns:

  • (Integer)

    0-59



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#time_end_secondInteger

Returns 0-59.

Returns:

  • (Integer)

    0-59



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#time_start_hourInteger

Returns 0-23.

Returns:

  • (Integer)

    0-23



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#time_start_minuteInteger

Returns 0-59.

Returns:

  • (Integer)

    0-59



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#time_start_secondInteger

Returns 0-59.

Returns:

  • (Integer)

    0-59



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#verbatim_collectorsString

Returns the literal string that indicates the collectors, typically taken right off the label.

Returns:

  • (String)

    the literal string that indicates the collectors, typically taken right off the label



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#verbatim_dateString

Returns the string representation, typically as taken from the label, of the date.

Returns:

  • (String)

    the string representation, typically as taken from the label, of the date



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#verbatim_datumString

TODO:

Returns:

  • (String)


183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#verbatim_elevationString

A string, typically sliced from verbatim_label, that represents all elevation data (min/max/precision) as recorded there.

Returns:

  • (String)


183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#verbatim_field_numberString

Returns the literal string/identifier used by the collector(s) to identify this particular collecting event, usually part of a series particular to one trip.

Returns:

  • (String)

    the literal string/identifier used by the collector(s) to identify this particular collecting event, usually part of a series particular to one trip



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#verbatim_geolocation_uncertaintyString

A string, typically sliced from verbatim_label, that represents the provided uncertainty value.

Returns:

  • (String)


183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#verbatim_habitatString

Returns a literal string, typically taken from the printed label, tha represents assertions about the habitat.

Returns:

  • (String)

    a literal string, typically taken from the printed label, tha represents assertions about the habitat



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#verbatim_labelString

A verbatim representation of label that defined this collecting event, typically, but not exclusively, used for retroactive data capture.

Returns:

  • (String)


183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#verbatim_latitudeString

A string, typically sliced from verbatim_label, that represents the latitude. Is used to derive mappable values, but does not get mapped itself.

Returns:

  • (String)


183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#verbatim_localityString

Returns a string, typically sliced from verbatim_label, that represents the locality, including any modifiers (2 mi NE).

Returns:

  • (String)

    a string, typically sliced from verbatim_label, that represents the locality, including any modifiers (2 mi NE).



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#verbatim_longitudeString

A string, typically sliced from verbatim_label, that represents the longitude. Is used to derive mappable values, but does not get mapped itself

Returns:

  • (String)


183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#verbatim_methodString

Returns the literal string that indicates the collecting method, typically taken right off the label.

Returns:

  • (String)

    the literal string that indicates the collecting method, typically taken right off the label



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/models/collecting_event.rb', line 183

class CollectingEvent < ApplicationRecord
include Housekeeping
include Shared::Citations
include Shared::DataAttributes
include Shared::Identifiers
include Shared::Notes
include Shared::Tags
include Shared::Depictions
include Shared::Labels
include Shared::Confidences
include Shared::Documentation
include Shared::ProtocolRelationships
include Shared::HasPapertrail
include SoftValidation
include Shared::Labels
include Shared::DwcOccurrenceHooks
include Shared::IsData

include CollectingEvent::GeoLocate
include CollectingEvent::Georeference

include CollectingEvent::DwcSerialization

include Shared::QueryBatchUpdate

ignore_whitespace_on(:document_label, :verbatim_label, :print_label)

NEARBY_DISTANCE = 5000
MINIMUM_ELEVATION = -11000
MAXIMUM_ELEVATION = 8500

attr_accessor :with_verbatim_data_georeference

# @return [Boolean]
#  When true, cached values are not built
attr_accessor :no_cached

# @return [Hash]
#   of known country/state/county values
attr_accessor :geographic_names

# See also CollectingEvent::GeoLocate

belongs_to :geographic_area, inverse_of: :collecting_events

has_one :accession_provider_role, class_name: 'AccessionProvider', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_one :deaccession_recipient_role, class_name: 'DeaccessionRecipient', as: :role_object, dependent: :destroy, inverse_of: :role_object

has_many :collection_objects, inverse_of: :collecting_event, dependent: :restrict_with_error
has_many :collector_roles, class_name: 'Collector', as: :role_object, dependent: :destroy, inverse_of: :role_object
has_many :collectors, -> { order('roles.position ASC') }, through: :collector_roles, source: :person, inverse_of: :collecting_events

# see also app/models/collecting_event/georeference.rb for more has_many

has_many :otus, -> { unscope(:order) }, through: :collection_objects, source: 'otu'

has_many :field_occurrences, inverse_of: :collecting_event

after_create do
  if with_verbatim_data_georeference
    generate_verbatim_data_georeference(true)
  end
end

before_save :set_times_to_nil_if_form_provided_blank

after_save :set_cached, unless: -> { no_cached }

# See also app/models/collecting_event/georeference.rb for more accepts_nested_attributes
accepts_nested_attributes_for :collectors, :collector_roles, allow_destroy: true

validate :check_verbatim_geolocation_uncertainty,
  :check_date_range,
  :check_elevation_range,
  :check_min_land_elevation,
  :check_max_land_elevation,
  :check_ma_range

validates_uniqueness_of :md5_of_verbatim_label, scope: [:project_id], unless: -> { verbatim_label.blank? }
validates_presence_of :verbatim_longitude, if: -> { verbatim_latitude.present? }
validates_presence_of :verbatim_latitude, if: -> { verbatim_longitude.present? }

validates :geographic_area, presence: true, allow_nil: true

validates :time_start_hour, time_hour: true
validates :time_start_minute, time_minute: true
validates :time_start_second, time_second: true

validates_presence_of :time_start_minute, if: -> { self.time_start_second.present? }
validates_presence_of :time_start_hour, if: -> { self.time_start_minute.present? }

validates :time_end_hour, time_hour: true
validates :time_end_minute, time_minute: true
validates :time_end_second, time_second: true

validates_presence_of :time_end_minute, if: -> { self.time_end_second.present? }
validates_presence_of :time_end_hour, if: -> { self.time_end_minute.present? }

validates :start_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}
validates :end_date_year, date_year: {min_year: 1000, max_year: Time.now.year + 5}

validates :start_date_month, date_month: true
validates :end_date_month, date_month: true

validates_presence_of :start_date_month, if: -> { !start_date_day.nil? }
validates_presence_of :end_date_month, if: -> { !end_date_day.nil? }

validates :end_date_day, date_day: {year_sym: :end_date_year, month_sym: :end_date_month},
  unless: -> { end_date_year.nil? || end_date_month.nil? }

validates :start_date_day, date_day: {year_sym: :start_date_year, month_sym: :start_date_month},
  unless: -> { start_date_year.nil? || start_date_month.nil? }

validates_presence_of :geographic_area_id, if: -> { meta_prioritize_geographic_area }

soft_validate(
  :sv_minimally_check_for_a_label,
  set: :minimally_check_for_a_label,
  name: 'Minimally check for a label',
  description: 'At least one label type, or field notes, should be provided')

soft_validate(
  :sv_missing_georeference,
  set: :georeference,
  name: 'Missing georeference',
  description: 'Georeference is missing')

soft_validate(
  :sv_georeference_matches_verbatim,
  set: :georeference,
  name: 'Georeference matches verbatim',
  description: 'Georeference matches verbatim latitude and longitude')

soft_validate(
  :sv_missing_geographic_area,
  set: :georeference,
  name: 'Missing geographic area',
  description: 'Georaphic area is missing')

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

# @param [String]
def verbatim_label=(value)
  write_attribute(:verbatim_label, value)
  write_attribute(:md5_of_verbatim_label, Utilities::Strings.generate_md5(value))
end

scope :used_recently, -> { joins(:collection_objects).includes(:collection_objects).where(collection_objects: { updated_at: 1.week.ago..Time.now } ).order('"collection_objects"."updated_at" DESC') }
scope :used_in_project, -> (project_id) { joins(:collection_objects).where( collection_objects: { project_id: } ) }

class << self

  #
  # Scopes
  #

  def select_optimized(user_id, project_id)
    h = {
      recent: (CollectingEvent.used_in_project(project_id)
        .where(collection_objects: {updated_by_id: user_id})
        .used_recently
        .distinct
        .limit(5)
        .order(:cached)
        .to_a +
      CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
      pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
    }

    h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
                 h[:recent]).uniq
    h
  end

  # @param [GeographicItem] geographic_item
  # @return [Scope]
  # TODO: use joins(:geographic_items).where(containing scope), simplied to
  def contained_within(geographic_item)
    CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
  end

  # @param [CollectingEvent Scope] collecting_events
  # @return [Scope] without self (if included)
  # TODO: DRY, use general form of this
  def not_including(collecting_events)
    where.not(id: collecting_events)
  end

  #
  # Other
  #

  # @param [String] search_start_date string in form 'yyyy/mm/dd'
  # @param [String] search_end_date string in form 'yyyy/mm/dd'
  # @param [String] partial_overlap 'on' or 'off'
  # @return [Scope] of selected collecting events
  # TODO: remove all of this for direct call to Queries::CollectingEvent::Filter
  def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
    allow_partial = (partial_overlap.downcase == 'off' ? false : true)
    q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
    where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
  end

  # @param [ActionController::Parameters] params in the style Rails of 'params'
  # @return [Scope] of selected collecting_events
  # TODO: deprecate for lib/queries/collecting_event/filter
  def filter_by(params)
    sql_string = ''
    if params.present? # not strictly necessary, but handy for debugging
      sql_string = Utilities::Dates.date_sql_from_params(params)

      # processing text data
      v_locality_fragment = params['verbatim_locality_text']
      any_label_fragment  = params['any_label_text']
      id_fragment = params['identifier_text']

      prefix = ''
      if v_locality_fragment.present?
        if sql_string.present?
          prefix = ' and '
        end
        sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
      end
      prefix = ''
      if any_label_fragment.present?
        if sql_string.present?
          prefix = 'and '
        end
        sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
        sql_string += " or print_label ilike '%#{any_label_fragment}%'"
        sql_string += " or document_label ilike '%#{any_label_fragment}%'"
        sql_string += ')'
      end

      if id_fragment.present?
        # @todo this still needs to be dealt with
      end

    end
    # find the records
    if sql_string.blank?
      collecting_events = CollectingEvent.none
    else
      collecting_events = CollectingEvent.where(sql_string).distinct
    end

    collecting_events
  end
end # end Class methods

# @param [String] lat
# @param [String] long
# @param [String] piece
# @param [Integer] project_id
# @param [Boolean] include_values true if to include records whicgh already have verbatim lat/longs
# @return [Scope] of matching collecting events
#   TODO: deprecate and move to filter
def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

# @return [Boolean]
#   test for minimal data
# TODO: consider renaming, reference new Merge code
def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

# @return [Boolean]
#   has a fully defined date
def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

# @return [Boolean]
#   has a fully defined date
def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

# @return [Integer, nil]
#   the start day  of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

# @return [Integer, nil]
#   the end day of the event from 1-365 (or 366 in leap years)
#   only returned when completely unambigouous, in theory could
#   be added for month/day combinations on, but that is uncertain
def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

# @return [String, nil]
#   an umabigously formatted string with missing parts indicated by ??
def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

# @return [Time]
#   This is for the purposes of computation, not display!
def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

# @return [Time]
#   This is for the purposes of computation, not display!
def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

# @return [String]
#   like 00, 00:00, or 00:00:00
def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

# @return [Array]
#   time_start and end if provided
def time_range
  [time_start, time_end].compact
end

# @return [Array]
#   date_start and end if provided
def date_range
  [start_date_string, end_date_string].compact
end

# CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)}
# .select {|ce| ce.georeferences.empty?}
# @param [Boolean] reference_self
# @param [Boolean] no_cached
# @return [Georeference::VerbatimData, false]
#   generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values
def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

# @return [GeographicItem, nil]
#    a GeographicItem instance representing a translation of the verbatim values, not saved
def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

# @return [Scope]
#   all geographic_items associated with this collecting_event through georeferences only
def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

# @return [GeographicItem, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

# @return [id, nil]
#  returns the geographic_item corresponding to the geographic area, if provided
def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

# @return [ [GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil] ]
#   a shape to represent the CE,
#   prioritize georeference over geographic_area
def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

# @param [GeographicItem]
# @return [String]
#   see how far away we are from another gi
def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

# @param [Double] distance in meters
# @return [Scope]
def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

# @return [Scope]
# Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self
def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# @return [Scope]
# Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

# DEPRECATED for shared code
# @param [String, String, Integer]
# @return [Scope]
def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

# @param [String]
#   one or more names from GeographicAreaType
# @return [Hash]
#    (
#    {'name' => [GAs]}
#   or
#   [{'name' => [GAs]}, {'name' => [GAs]}]
#   )
#     one hash, consisting of a country name paired with an array of the corresponding GAs, or
#     an array of all of the hashes (name/GA pairs),
#     which are country_level, and have GIs containing the (GI and/or EGI) of this CE
# @todo this needs more work, possibily direct AREL table manipulation.
def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

def has_cached_geographic_names?
  geographic_names != {}
end

# getter for attr :geographic_names
def geographic_names
  return @geographic_names if !@geographic_names.nil?

  @geographic_names ||= {
    country: cached_level0_geographic_name,
    state: cached_level1_geographic_name,
    county: cached_level2_geographic_name
  }.delete_if{|k,v| v.nil?}

  # TODO: Once cached index is updated this should be removed, it is
  # costly for records with geographic_area_id without country, state, county
  if @geographic_names == {} && geographic_area_id
    r = get_geographic_name_classification
    set_cached_geographic_names
  end

  @geographic_names ||= {}
end

# @return Hash
#  a geographic_name_classification.
# This prioritizes Georeferences over GeographicAreas!
def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

# @return [Symbol, nil]
#   determines (prioritizes) the method to be used to decided the geographic name classification
#   (string labels for country, state, county) for this collecting_event.
def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

# @return [Array of GeographicItems containing this target]
#   GeographicItems are those that contain either the georeference or, if there are none,
#   the geographic area
def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

# @return [Hash]
def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a state name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are state_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

# returns either:   ( {'name' => [GAs]} or [{'name' => [GAs]}, {'name' => [GAs]}])
#   one hash, consisting of a county name paired with an array of the corresponding GAs, or
#   an array of all of the hashes (name/GA pairs),
#   which are county_level, and have GIs containing the (GI and/or EGI) of this CE
# @return [Hash]
def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

# @param [Hash]
# @return [String]
def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

# @return [String]
def country_name
  name_from_geopolitical_hash(countries_hash)
end

# @return [String]
def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

# @return [String]
def state_name
  state_or_province_name
end

# @return [String]
def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

alias county_name county_or_equivalent_name

# TODO: DRY with helper methods, these are now outdated approaches
# @return [GeoJSON::Feature]
#   the first geographic item of the first georeference on this collecting event
def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

# TODO: DRY with helper methods, these are now outdated approaches
#   i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')
def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end


# @param [Float] delta_z, will be used to fill in the z coordinate of the point
# @return [RGeo::Geographic::ProjectedPointImpl, nil]
#   for the *verbatim* latitude/longitude only
def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end

# @return [Symbol, nil]
#   the name of the method that will return an Rgeo object that represent
#   the "preferred" centroid for this collecting event
def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

# @return [Rgeo::Geographic::ProjectedPointImpl, nil]
def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#def level0_name
#  return cached_level0_name if cached_level0_name
#  cache_geographic_names[:country]
#end

#def level1_name
#  return cached_level1_name if cached_level1_name
#  cache_geographic_names[:state]
#end

#def level2_name
#  return cached_level2_name if cached_level2_name
#  cache_geographic_names[:county]
#end

# def cached_level0_name
#   return cached_level0_name if cached_level0_name
#   cache_geographic_names[:country]
# end

# @return [CollectingEvent]
#  the instance may not be valid!
def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

# @return [String, nil]
#   a string used in DWC reportedBy and ultimately label generation
#   TODO: include initials when we find out a clean way of producing them
# yes, it's a Helper
def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

# @return [Scalar (Int, Float, etc), nil]
def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

class << self

  # @return [Hash, false]
  def batch_update(params)

    request = QueryBatchRequest.new(
      klass: 'CollectingEvent',
      object_filter_params: params[:collecting_event_query],
      object_params: params[:collecting_event],
      async_cutoff: params[:async_cutoff] || 26,
      preview: params[:preview],
    )

    request.cap = 1000
    request.cap_reason = 'Max 500 updated at a time.'
    query_batch_update(request)
  end
end

protected

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

# TODO: A time sync.
def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

end

#with_verbatim_data_georeferenceObject

Returns the value of attribute with_verbatim_data_georeference.



214
215
216
# File 'app/models/collecting_event.rb', line 214

def with_verbatim_data_georeference
  @with_verbatim_data_georeference
end

Class Method Details

.batch_update(params) ⇒ Hash, false

Returns:

  • (Hash, false)


1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
# File 'app/models/collecting_event.rb', line 1069

def batch_update(params)

  request = QueryBatchRequest.new(
    klass: 'CollectingEvent',
    object_filter_params: params[:collecting_event_query],
    object_params: params[:collecting_event],
    async_cutoff: params[:async_cutoff] || 26,
    preview: params[:preview],
  )

  request.cap = 1000
  request.cap_reason = 'Max 500 updated at a time.'
  query_batch_update(request)
end

.contained_within(geographic_item) ⇒ Scope

TODO: use joins(:geographic_items).where(containing scope), simplied to

Parameters:

Returns:

  • (Scope)


368
369
370
# File 'app/models/collecting_event.rb', line 368

def contained_within(geographic_item)
  CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id))
end

.filter_by(params) ⇒ Scope

TODO: deprecate for lib/queries/collecting_event/filter

Parameters:

  • params (ActionController::Parameters)

    in the style Rails of ‘params’

Returns:

  • (Scope)

    of selected collecting_events



397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
# File 'app/models/collecting_event.rb', line 397

def filter_by(params)
  sql_string = ''
  if params.present? # not strictly necessary, but handy for debugging
    sql_string = Utilities::Dates.date_sql_from_params(params)

    # processing text data
    v_locality_fragment = params['verbatim_locality_text']
    any_label_fragment  = params['any_label_text']
    id_fragment = params['identifier_text']

    prefix = ''
    if v_locality_fragment.present?
      if sql_string.present?
        prefix = ' and '
      end
      sql_string += "#{ prefix }verbatim_locality ilike '%#{v_locality_fragment}%'"
    end
    prefix = ''
    if any_label_fragment.present?
      if sql_string.present?
        prefix = 'and '
      end
      sql_string += "#{ prefix }(verbatim_label ilike '%#{any_label_fragment}%'"
      sql_string += " or print_label ilike '%#{any_label_fragment}%'"
      sql_string += " or document_label ilike '%#{any_label_fragment}%'"
      sql_string += ')'
    end

    if id_fragment.present?
      # @todo this still needs to be dealt with
    end

  end
  # find the records
  if sql_string.blank?
    collecting_events = CollectingEvent.none
  else
    collecting_events = CollectingEvent.where(sql_string).distinct
  end

  collecting_events
end

.in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on') ⇒ Scope

TODO: remove all of this for direct call to Queries::CollectingEvent::Filter

Parameters:

  • search_start_date (String) (defaults to: nil)

    string in form ‘yyyy/mm/dd’

  • search_end_date (String) (defaults to: nil)

    string in form ‘yyyy/mm/dd’

  • partial_overlap (String) (defaults to: 'on')

    ‘on’ or ‘off’

Returns:

  • (Scope)

    of selected collecting events



388
389
390
391
392
# File 'app/models/collecting_event.rb', line 388

def in_date_range(search_start_date: nil, search_end_date: nil, partial_overlap: 'on')
  allow_partial = (partial_overlap.downcase == 'off' ? false : true)
  q = Queries::CollectingEvent::Filter.new(start_date: search_start_date, end_date: search_end_date, partial_overlap_dates: allow_partial)
  where(q.between_date_range_facet.to_sql).distinct # TODO: uniq should likely not be here
end

.not_including(collecting_events) ⇒ Scope

TODO: DRY, use general form of this

Parameters:

Returns:

  • (Scope)

    without self (if included)



375
376
377
# File 'app/models/collecting_event.rb', line 375

def not_including(collecting_events)
  where.not(id: collecting_events)
end

.select_optimized(user_id, project_id) ⇒ Object

Scopes



347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
# File 'app/models/collecting_event.rb', line 347

def select_optimized(user_id, project_id)
  h = {
    recent: (CollectingEvent.used_in_project(project_id)
      .where(collection_objects: {updated_by_id: user_id})
      .used_recently
      .distinct
      .limit(5)
      .order(:cached)
      .to_a +
    CollectingEvent.where(project_id:, updated_by_id: user_id, created_at: 3.hours.ago..Time.now).limit(5).to_a).uniq,
    pinboard: CollectingEvent.pinned_by(user_id).pinned_in_project(project_id).to_a
  }

  h[:quick] = (CollectingEvent.pinned_by(user_id).pinboard_inserted.pinned_in_project(project_id).to_a  +
               h[:recent]).uniq
  h
end

Instance Method Details

#all_geographic_itemsScope

Returns all geographic_items associated with this collecting_event through georeferences only.

Returns:

  • (Scope)

    all geographic_items associated with this collecting_event through georeferences only



608
609
610
611
612
613
# File 'app/models/collecting_event.rb', line 608

def all_geographic_items
  GeographicItem.
    joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    where(['(g1.collecting_event_id = ? OR g2.collecting_event_id = ?) AND (g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)', self.id, self.id])
end

#build_verbatim_geographic_itemGeographicItem?

Returns a GeographicItem instance representing a translation of the verbatim values, not saved.

Returns:

  • (GeographicItem, nil)

    a GeographicItem instance representing a translation of the verbatim values, not saved



594
595
596
597
598
599
600
601
602
603
604
# File 'app/models/collecting_event.rb', line 594

def build_verbatim_geographic_item
  if self.verbatim_latitude && self.verbatim_longitude && !self.new_record?
    local_latitude  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude)
    local_longitude = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude)
    elev            = Utilities::Geo.distance_in_meters(verbatim_elevation).to_f
    point           = Gis::FACTORY.point(local_latitude, local_longitude, elev)
    GeographicItem.new(point:)
  else
    nil
  end
end

#check_date_rangeObject (protected)



1147
1148
1149
1150
1151
1152
1153
1154
# File 'app/models/collecting_event.rb', line 1147

def check_date_range
  begin
    errors.add(:base, 'End date is earlier than start date.') if has_start_date? && has_end_date? && (start_date > end_date)
  rescue
    errors.add(:base, 'Start and/or end date invalid.')
  end
  errors.add(:base, 'End date without start date.') if (has_end_date? && !has_start_date?)
end

#check_elevation_rangeObject (protected)



1175
1176
1177
# File 'app/models/collecting_event.rb', line 1175

def check_elevation_range
  errors.add(:maximum_elevation, 'Maximum elevation is lower than minimum elevation.') if minimum_elevation.present? && maximum_elevation.present? && maximum_elevation < minimum_elevation
end

#check_ma_rangeObject (protected)



1156
1157
1158
# File 'app/models/collecting_event.rb', line 1156

def check_ma_range
  errors.add(:min_ma, 'Min ma is < Max ma.') if min_ma.present? && max_ma.present? && min_ma > max_ma
end

#check_max_land_elevationObject (protected)



1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
# File 'app/models/collecting_event.rb', line 1160

def check_max_land_elevation
  m = 'This is Earth, not Bespin, contact us sky collector.'
  if (maximum_elevation.present? && maximum_elevation > MAXIMUM_ELEVATION)
    errors.add(:maximum_elevation, m)
  end

  if (minimum_elevation.present? && minimum_elevation > MAXIMUM_ELEVATION)  # 2023 LLM
    errors.add(:minimum_elevation, m)
  end
end

#check_min_land_elevationObject (protected)



1171
1172
1173
# File 'app/models/collecting_event.rb', line 1171

def check_min_land_elevation
  errors.add(:minimum_elevation, 'You know a deeper trench than we do, contact us.') if minimum_elevation.present? && minimum_elevation < MINIMUM_ELEVATION # 2023 LLM
end

#check_verbatim_geolocation_uncertaintyObject (protected)



1143
1144
1145
# File 'app/models/collecting_event.rb', line 1143

def check_verbatim_geolocation_uncertainty
  errors.add(:verbatim_geolocation_uncertainty, 'Provide both verbatim_latitude and verbatim_longitude if you provide verbatim_uncertainty.') if verbatim_geolocation_uncertainty.present? && verbatim_longitude.blank? && verbatim_latitude.blank?
end

#clone(annotations: false, incremented_identifier_id: nil) ⇒ CollectingEvent

Returns the instance may not be valid!.

Returns:



1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
# File 'app/models/collecting_event.rb', line 1000

def clone(annotations: false, incremented_identifier_id: nil)
  a = dup

  CollectingEvent.transaction do
    begin
      a.created_by_id = nil

      if a.verbatim_label.present?
        a.verbatim_label = [verbatim_label, "[CLONED FROM #{id}", "at #{Time.now}]"].compact.join(' ')
      end

      roles.each do |r|
        a.collector_roles.build(person: r.person, position: r.position)
      end

      if georeferences.load.any?
        # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position}
        georeferences.each do |g|
          i = g.dup

          g.georeferencer_roles.each do |r|
            i.georeferencer_roles.build(person: r.person, position: r.position)
          end

          a.georeferences << i

        end
      end

      if incremented_identifier_id
        add_incremented_identifier(to_object: a, incremented_identifier_id:)
      end

      if !annotations.blank? # TODO: boolean param this
        clone_annotations(to_object: a, except: [:identifiers])
      end

      a.save! # TODO: confirm behaviour is OK in case of comprehensive.

    rescue ActiveRecord::RecordInvalid
      raise ActiveRecord::Rollback
    end
    a
  end
end

#collecting_events_contained_in_errorScope

Find other CEs that have GRs whose GIs or EGIs are contained in the EGI

Returns:

  • (Scope)


681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
# File 'app/models/collecting_event.rb', line 681

def collecting_events_contained_in_error
  # find all the GIs and EGIs associated with CEs
  # TODO: this will be impossibly slow in present form
  pieces = GeographicItem.with_collecting_event_through_georeferences.to_a

  me = self.error_geographic_items.first.geo_object
  gi = []
  # collect all the GIs which are within the EGI
  pieces.each { |o|
    gi.push(o) if o.geo_object.within?(me)
  }
  # collect all the CEs which refer to these GIs
  ce = []
  gi.each { |o|
    ce.push(o.collecting_events_through_georeferences.to_a)
    ce.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo Directly map this
  pieces = CollectingEvent.where(id: ce.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

#collecting_events_intersecting_withScope

Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self

Returns:

  • (Scope)


664
665
666
667
668
669
670
671
672
673
674
675
676
# File 'app/models/collecting_event.rb', line 664

def collecting_events_intersecting_with
  pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct
  gr     = [] # all collecting events for a geographic_item

  pieces.each { |o|
    gr.push(o.collecting_events_through_georeferences.to_a)
    gr.push(o.collecting_events_through_georeference_error_geographic_item.to_a)
  }

  # @todo change 'id in (?)' to some other sql construct
  pieces = CollectingEvent.where(id: gr.flatten.map(&:id).uniq)
  pieces.not_including(self)
end

#collecting_events_within_radius_of(distance) ⇒ Scope

Parameters:

  • distance (Double)

    in meters

Returns:

  • (Scope)


652
653
654
655
656
657
658
659
660
# File 'app/models/collecting_event.rb', line 652

def collecting_events_within_radius_of(distance)
  return CollectingEvent.none if !preferred_georeference
  geographic_item_id = preferred_georeference.geographic_item_id
  # geographic_item_id = preferred_georeference.try(:geographic_item_id)
  # geographic_item_id = Georeference.where(collecting_event_id: id).first.geographic_item_id if geographic_item_id.nil?
  CollectingEvent.not_self(self)
    .joins(:geographic_items)
    .where(GeographicItem.within_radius_of_item_sql(geographic_item_id, distance))
end

#collector_namesString?

yes, it’s a Helper

Returns:

  • (String, nil)

    a string used in DWC reportedBy and ultimately label generation TODO: include initials when we find out a clean way of producing them



1050
1051
1052
# File 'app/models/collecting_event.rb', line 1050

def collector_names
  [Utilities::Strings.authorship_sentence(collectors.collect{|a| a.last_name}), verbatim_collectors].compact.first
end

#containing_geographic_itemsArray of GeographicItems containing this target

Returns GeographicItems are those that contain either the georeference or, if there are none, the geographic area.

Returns:

  • (Array of GeographicItems containing this target)

    GeographicItems are those that contain either the georeference or, if there are none, the geographic area



819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
# File 'app/models/collecting_event.rb', line 819

def containing_geographic_items
  gi_list = []
  if self.georeferences.any?
    # gather all the GIs which contain this GI or EGI
    #
    #  Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set
    #   !! and there was no tests broken
    # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq
    gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq

  else
    # use geographic_area only if there are no GIs or EGIs
    unless self.geographic_area.nil?
      # unless self.geographic_area.geographic_items.empty?
      # we need to use the geographic_area directly
      gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq
      # end
    end
  end
  gi_list
end

#counties_hashHash

returns either: ( => [GAs] or [=> [GAs], => [GAs]])

one hash, consisting of a county name paired with an array of the corresponding GAs, or
an array of all of the hashes (name/GA pairs),
which are county_level, and have GIs containing the (GI and/or EGI) of this CE

Returns:

  • (Hash)


860
861
862
# File 'app/models/collecting_event.rb', line 860

def counties_hash
  name_hash(GeographicAreaType::COUNTY_LEVEL_TYPES)
end

#countries_hashHash

Returns:

  • (Hash)


842
843
844
# File 'app/models/collecting_event.rb', line 842

def countries_hash
  name_hash(GeographicAreaType::COUNTRY_LEVEL_TYPES)
end

#country_nameString

Returns:

  • (String)


881
882
883
# File 'app/models/collecting_event.rb', line 881

def country_name
  name_from_geopolitical_hash(countries_hash)
end

#county_or_equivalent_nameString Also known as: county_name

Returns:

  • (String)


896
897
898
# File 'app/models/collecting_event.rb', line 896

def county_or_equivalent_name
  name_from_geopolitical_hash(counties_hash)
end

#date_rangeArray

Returns date_start and end if provided.

Returns:

  • (Array)

    date_start and end if provided



564
565
566
# File 'app/models/collecting_event.rb', line 564

def date_range
  [start_date_string, end_date_string].compact
end

#distance_to(geographic_item_id) ⇒ String

Returns see how far away we are from another gi.

Parameters:

Returns:

  • (String)

    see how far away we are from another gi



646
647
648
# File 'app/models/collecting_event.rb', line 646

def distance_to(geographic_item_id)
  GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id)
end

#dwc_occurrencesObject



322
323
324
325
326
327
328
329
330
# File 'app/models/collecting_event.rb', line 322

def dwc_occurrences
  # Through CollectionObjects
  DwcOccurrence
    .joins("JOIN collection_objects co on dwc_occurrence_object_id = co.id AND dwc_occurrence_object_type = 'CollectionObject'")
    .where(co: {collecting_event_id: id})
    .distinct

  # TODO: Throuch FieldOccurrence
end

#end_dateTime

Returns This is for the purposes of computation, not display!.

Returns:

  • (Time)

    This is for the purposes of computation, not display!



534
535
536
# File 'app/models/collecting_event.rb', line 534

def end_date
  Utilities::Dates.nomenclature_date(end_date_day, end_date_month, end_date_year)
end

#end_date_stringString?

Returns an umabigously formatted string with missing parts indicated by ??.

Returns:

  • (String, nil)

    an umabigously formatted string with missing parts indicated by ??



528
529
530
# File 'app/models/collecting_event.rb', line 528

def end_date_string
  Utilities::Dates.from_parts(end_date_year, end_date_month, end_date_day) if some_end_date?
end

#end_day_of_yearInteger?

Returns the end day of the event from 1-365 (or 366 in leap years) only returned when completely unambigouous, in theory could be added for month/day combinations on, but that is uncertain.

Returns:

  • (Integer, nil)

    the end day of the event from 1-365 (or 366 in leap years) only returned when completely unambigouous, in theory could be added for month/day combinations on, but that is uncertain



515
516
517
518
# File 'app/models/collecting_event.rb', line 515

def end_day_of_year
  return unless has_end_date?
  Date.new(end_date_year, end_date_month, end_date_day).yday
end

#generate_verbatim_data_georeference(reference_self = false, no_cached: false) ⇒ Georeference::VerbatimData, false

CollectingEvent.select {|d| !(d.verbatim_latitude.nil? || d.verbatim_longitude.nil?)} .select {|ce| ce.georeferences.empty?}

Parameters:

  • reference_self (Boolean) (defaults to: false)
  • no_cached (Boolean) (defaults to: false)

Returns:

  • (Georeference::VerbatimData, false)

    generates (creates) a Georeference::VerbatimReference from verbatim_latitude and verbatim_longitude values



574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
# File 'app/models/collecting_event.rb', line 574

def generate_verbatim_data_georeference(reference_self = false, no_cached: false)
  return false if (verbatim_latitude.nil? || verbatim_longitude.nil?)
  begin
    CollectingEvent.transaction do
      vg_attributes = {collecting_event_id: id.to_s, no_cached:}
      vg_attributes.merge!(by: creator.id, project_id:) if reference_self
      a = ::Georeference::VerbatimData.new(vg_attributes)
      if a.valid?
        a.save
      end
      return a
    end
  rescue ActiveRecord::RecordInvalid
    raise
  end
  false
end

#geo_json_data[GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil]

Returns a shape to represent the CE, prioritize georeference over geographic_area.

Returns:

  • ([GeoJSON, Georeference|GeographicArea, object_id ], [nil, nil, nil])

    a shape to represent the CE, prioritize georeference over geographic_area



633
634
635
636
637
638
639
640
641
# File 'app/models/collecting_event.rb', line 633

def geo_json_data
  if a = preferred_georeference #  preferred_georeference_geographic_item_id # NOT RIGHT
    return Gis::GeoJSON.quick_geo_json( a.geographic_item_id ), 'Georeference', a.id
  elsif b = geographic_area_default_geographic_item_id
    return Gis::GeoJSON.quick_geo_json(b), 'GeographicArea', geographic_area_id
  else
    return nil, nil, nil
  end
end

#geographic_area_default_geographic_itemGeographicItem?

Returns the geographic_item corresponding to the geographic area, if provided

Returns:

  • (GeographicItem, nil)

    returns the geographic_item corresponding to the geographic area, if provided



617
618
619
# File 'app/models/collecting_event.rb', line 617

def geographic_area_default_geographic_item
  try(:geographic_area).try(:default_geographic_item)
end

#geographic_area_default_geographic_item_idid?

Returns the geographic_item corresponding to the geographic area, if provided

Returns:

  • (id, nil)

    returns the geographic_item corresponding to the geographic area, if provided



623
624
625
626
627
628
# File 'app/models/collecting_event.rb', line 623

def geographic_area_default_geographic_item_id
  GeographicAreasGeographicItem.where(geographic_area_id:)
    .default_geographic_item_data
    .pluck(:geographic_item_id)
    .first
end

#geographic_name_classification_methodSymbol?

Returns determines (prioritizes) the method to be used to decided the geographic name classification (string labels for country, state, county) for this collecting_event.

Returns:

  • (Symbol, nil)

    determines (prioritizes) the method to be used to decided the geographic name classification (string labels for country, state, county) for this collecting_event.



805
806
807
808
809
810
811
812
813
814
# File 'app/models/collecting_event.rb', line 805

def geographic_name_classification_method
  # Over-ride first
  return :geographic_area if meta_prioritize_geographic_area

  return :preferred_georeference if !preferred_georeference.nil?
  return :geographic_area_with_shape if geographic_area.try(:has_shape?)
  return :geographic_area if geographic_area
  return :verbatim_map_center if verbatim_map_center
  nil
end

#geolocate_uncertainty_in_metersScalar (Int, Float, etc)?

Returns:

  • (Scalar (Int, Float, etc), nil)


1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
# File 'app/models/collecting_event.rb', line 1055

def geolocate_uncertainty_in_meters
  if verbatim_geolocation_uncertainty.present?
    begin
      a = verbatim_geolocation_uncertainty.to_unit
      return a.convert_to('m').scalar if (a =~ '1 m'.to_unit)
    rescue ArgumentError
    end
  end
  nil
end

#get_geographic_name_classificationObject

This prioritizes Georeferences over GeographicAreas!

Returns:

  • Hash a geographic_name_classification.



780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
# File 'app/models/collecting_event.rb', line 780

def get_geographic_name_classification
  case geographic_name_classification_method
  when :preferred_georeference
    r = preferred_georeference.geographic_item.geographic_name_hierarchy
  when :geographic_area_with_shape # geographic_area.try(:has_shape?)
    # very quick
    r = geographic_area.geographic_name_classification # do not round trip to the geographic_item, it just points back to the geographic area
    # slow
    #   rarely hit, this uses the geographic_area shape itself to then find what contains it, and then classify using that
    #   it will by definition be partial
    r = geographic_area.default_geographic_item.inferred_geographic_name_hierarchy if r == {}
  when :geographic_area # elsif geographic_area
    # quick
    r = geographic_area.geographic_name_classification
  when :verbatim_map_center # elsif map_center
    # slowest
    r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center)
  end
  r ||= {}
  r
end

#has_cached_geographic_names?Boolean

Returns:

  • (Boolean)


753
754
755
# File 'app/models/collecting_event.rb', line 753

def has_cached_geographic_names?
  geographic_names != {}
end

#has_collectors?Boolean

Returns:

  • (Boolean)


478
479
480
# File 'app/models/collecting_event.rb', line 478

def has_collectors?
  verbatim_collectors.present? || collectors.any?
end

#has_data?Boolean

TODO: consider renaming, reference new Merge code

Returns:

  • (Boolean)

    test for minimal data



466
467
468
469
470
471
472
# File 'app/models/collecting_event.rb', line 466

def has_data?
  CollectingEvent.core_attributes.each do |a|
    return true if self.send(a).present?
  end
  return true if georeferences.any?
  false
end

#has_end_date?Boolean

Returns has a fully defined date.

Returns:

  • (Boolean)

    has a fully defined date



490
491
492
# File 'app/models/collecting_event.rb', line 490

def has_end_date?
  end_date_day.present? && end_date_month.present? && end_date_year.present?
end

#has_some_date?Boolean

Returns:

  • (Boolean)


474
475
476
# File 'app/models/collecting_event.rb', line 474

def has_some_date?
  verbatim_date.present? || some_start_date? || some_end_date?
end

#has_start_date?Boolean

Returns has a fully defined date.

Returns:

  • (Boolean)

    has a fully defined date



484
485
486
# File 'app/models/collecting_event.rb', line 484

def has_start_date?
  start_date_day.present? && start_date_month.present? && start_date_year.present?
end

#map_centerRgeo::Geographic::ProjectedPointImpl?

Returns:

  • (Rgeo::Geographic::ProjectedPointImpl, nil)


961
962
963
964
965
966
967
968
969
970
971
972
# File 'app/models/collecting_event.rb', line 961

def map_center
  case map_center_method
  when :preferred_georeference
    preferred_georeference.geographic_item.centroid
  when :verbatim_map_center
    verbatim_map_center
  when :geographic_area
    geographic_area.default_geographic_item.centroid
  else
    nil
  end
end

#map_center_methodSymbol?

Returns the name of the method that will return an Rgeo object that represent the “preferred” centroid for this collecting event.

Returns:

  • (Symbol, nil)

    the name of the method that will return an Rgeo object that represent the “preferred” centroid for this collecting event



953
954
955
956
957
958
# File 'app/models/collecting_event.rb', line 953

def map_center_method
  return :preferred_georeference if preferred_georeference # => { georeferenceProtocol => ?  }
  return :verbatim_map_center if verbatim_map_center # => { }
  return :geographic_area if geographic_area&.has_shape?
  nil
end

#name_from_geopolitical_hash(name_hash) ⇒ String

Parameters:

  • (Hash)

Returns:

  • (String)


866
867
868
869
870
871
872
873
874
875
876
877
878
# File 'app/models/collecting_event.rb', line 866

def name_from_geopolitical_hash(name_hash)
  return nil if name_hash.keys.count == 0
  return name_hash.keys.first if name_hash.keys.count == 1
  most_key   = nil
  most_count = 0
  name_hash.keys.sort.each do |k| # alphabetically first (keys are unordered)
    if name_hash[k].size > most_count
      most_count = name_hash[k].size
      most_key   = k
    end
  end
  most_key
end

#name_hash(types) ⇒ Hash

TODO:

this needs more work, possibily direct AREL table manipulation.

or

[{'name' => [GAs]}, {'name' => [GAs]}]
)
  one hash, consisting of a country name paired with an array of the corresponding GAs, or
  an array of all of the hashes (name/GA pairs),
  which are country_level, and have GIs containing the (GI and/or EGI) of this CE

Parameters:

  • one (String)

    or more names from GeographicAreaType

Returns:

  • (Hash)

    ( => [GAs]



727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
# File 'app/models/collecting_event.rb', line 727

def name_hash(types)
  retval  = {}
  gi_list = containing_geographic_items

  # there are a few ways we can end up with no GIs
  # unless gi_list.nil? # no references GeographicAreas or Georeferences at all, or
  unless gi_list.empty? # no available GeographicItems to test
    # map the resulting GIs to their corresponding GAs
    # pieces  = GeographicItem.where(id: gi_list.flatten.map(&:id).uniq)
    # pieces = gi_list
    ga_list = GeographicArea.joins(:geographic_area_type, :geographic_areas_geographic_items).
      where(geographic_area_types: {name: types},
            geographic_areas_geographic_items: {geographic_item_id: gi_list}).distinct

    # WAS: now find all of the GAs which have the same names as the ones we collected.

    # map the names to an array of results
    ga_list.each { |i|
      retval[i.name] ||= [] # if we haven't come across this name yet, set it to point to a blank array
      retval[i.name].push i # we now have at least a blank array, push the result into it
    }
  end
  # end
  retval
end

#namesObject



974
975
976
# File 'app/models/collecting_event.rb', line 974

def names
  geographic_area.nil? ? [] : geographic_area.self_and_ancestors.where("name != 'Earth'").collect { |ga| ga.name }
end

#nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10) ⇒ Scope

DEPRECATED for shared code

Parameters:

  • (String, String, Integer)

Returns:

  • (Scope)


707
708
709
710
711
712
713
# File 'app/models/collecting_event.rb', line 707

def nearest_by_levenshtein(compared_string = nil, column = 'verbatim_locality', limit = 10)
  return CollectingEvent.none if compared_string.nil?
  order_str = CollectingEvent.send(:sanitize_sql_for_conditions, ["levenshtein(collecting_events.#{column}, ?)", compared_string])
  CollectingEvent.where('id <> ?', id.to_s).
    order(Arel.sql(order_str)).
    limit(limit)
end

#set_cachedObject (protected)



1110
1111
1112
1113
# File 'app/models/collecting_event.rb', line 1110

def set_cached
  set_cached_geographic_names # if saved_change_to_attribute?(:geographic_area_id)
  set_cached_cached
end

#set_cached_cachedObject (protected)

TODO: A time sync.



1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
# File 'app/models/collecting_event.rb', line 1116

def set_cached_cached
  c = {}
  v = [verbatim_label, print_label, document_label].compact.first

  if v
    string = v
  else
    name = [ geographic_names[:country], geographic_names[:state], geographic_names[:county]].compact.join(': ')
    date = [start_date_string, end_date_string].compact.join('-')
    place_date = [verbatim_locality, date].compact.join(', ')
    # TODO: When a method to reference Collector roles is created update Collector to trigger this cached method
    string = [name, place_date, verbatim_collectors, verbatim_method].reject{|a| a.blank? }.join("\n")
  end

  string = "[#{id}]" if string.blank?

  c[:cached] = string

  update_columns(c)
end

#set_cached_geographic_namesObject (protected)



1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
# File 'app/models/collecting_event.rb', line 1087

def set_cached_geographic_names
  # prevent a second call to get if we've already tried through
  values = get_geographic_name_classification

  if values.empty?
    update_columns(
      cached_level0_geographic_name: nil,
      cached_level1_geographic_name: nil,
      cached_level2_geographic_name: nil)
    @geographic_names = {}
  else
    update_columns(
      cached_level0_geographic_name: values[:country],
      cached_level1_geographic_name: values[:state],
      cached_level2_geographic_name: values[:county])

    # Update attribute to ensure it can be used post save
    @geographic_names = values
    @geographic_names.delete_if{|k,v| v.nil?}
    @geographic_names
  end
end

#set_times_to_nil_if_form_provided_blankObject (protected)



1137
1138
1139
1140
1141
# File 'app/models/collecting_event.rb', line 1137

def set_times_to_nil_if_form_provided_blank
  matches = ['0001-01-01 00:00:00 UTC', '2000-01-01 00:00:00 UTC']
  write_attribute(:time_start, nil) if matches.include?(self.time_start.to_s)
  write_attribute(:time_end, nil) if matches.include?(self.time_end.to_s)
end

#similar_lat_longs(lat, long, project_id, piece = '', include_values = true) ⇒ Scope

Returns of matching collecting events TODO: deprecate and move to filter.

Parameters:

  • lat (String)
  • long (String)
  • piece (String) (defaults to: '')
  • project_id (Integer)
  • include_values (Boolean) (defaults to: true)

    true if to include records whicgh already have verbatim lat/longs

Returns:

  • (Scope)

    of matching collecting events TODO: deprecate and move to filter



448
449
450
451
452
453
454
455
456
457
458
459
460
461
# File 'app/models/collecting_event.rb', line 448

def similar_lat_longs(lat, long, project_id, piece = '', include_values = true)
  sql = '('
  sql += "verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(lat)}%'" if lat.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(long)}%'" if long.present?
  sql += " or verbatim_label LIKE '%#{::Utilities::Strings.escape_single_quote(piece)}%'" if piece.present?
  sql += ')'
  sql += ' and (verbatim_latitude is null or verbatim_longitude is null)' unless include_values

  retval = CollectingEvent.where(sql)
    .with_project_id(project_id)
    .order(:id)
    .where.not(id:).distinct
  retval
end

#some_end_date?Boolean

Returns:

  • (Boolean)


498
499
500
# File 'app/models/collecting_event.rb', line 498

def some_end_date?
  [end_date_day, end_date_month, end_date_year].compact.any?
end

#some_start_date?Boolean

Returns:

  • (Boolean)


494
495
496
# File 'app/models/collecting_event.rb', line 494

def some_start_date?
  [start_date_day, start_date_month, start_date_year].compact.any?
end

#start_dateTime

Returns This is for the purposes of computation, not display!.

Returns:

  • (Time)

    This is for the purposes of computation, not display!



540
541
542
# File 'app/models/collecting_event.rb', line 540

def start_date
  Utilities::Dates.nomenclature_date(start_date_day, start_date_month, start_date_year)
end

#start_date_stringString?

Returns an umabigously formatted string with missing parts indicated by ??.

Returns:

  • (String, nil)

    an umabigously formatted string with missing parts indicated by ??



522
523
524
# File 'app/models/collecting_event.rb', line 522

def start_date_string
  Utilities::Dates.from_parts(start_date_year, start_date_month, start_date_day) if some_start_date?
end

#start_day_of_yearInteger?

Returns the start day of the event from 1-365 (or 366 in leap years) only returned when completely unambigouous, in theory could be added for month/day combinations on, but that is uncertain.

Returns:

  • (Integer, nil)

    the start day of the event from 1-365 (or 366 in leap years) only returned when completely unambigouous, in theory could be added for month/day combinations on, but that is uncertain



506
507
508
509
# File 'app/models/collecting_event.rb', line 506

def start_day_of_year
  return unless has_start_date?
  Date.new(start_date_year, start_date_month, start_date_day).yday
end

#state_nameString

Returns:

  • (String)


891
892
893
# File 'app/models/collecting_event.rb', line 891

def state_name
  state_or_province_name
end

#state_or_province_nameString

Returns:

  • (String)


886
887
888
# File 'app/models/collecting_event.rb', line 886

def state_or_province_name
  name_from_geopolitical_hash(states_hash)
end

#states_hashHash

returns either: ( => [GAs] or [=> [GAs], => [GAs]])

one hash, consisting of a state name paired with an array of the corresponding GAs, or
an array of all of the hashes (name/GA pairs),
which are state_level, and have GIs containing the (GI and/or EGI) of this CE

Returns:

  • (Hash)


851
852
853
# File 'app/models/collecting_event.rb', line 851

def states_hash
  name_hash(GeographicAreaType::STATE_LEVEL_TYPES)
end

#sv_georeference_matches_verbatimObject (protected)



1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
# File 'app/models/collecting_event.rb', line 1183

def sv_georeference_matches_verbatim
  if a = georeferences.where(type: 'Georeference::VerbatimData').first
    d_lat = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude).to_f
    d_long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude).to_f
    if (a.latitude.to_f !=  d_lat)
      soft_validations.add(
        :base,
        "Verbatim latitude #{verbatim_latitude}: (#{d_lat}) and point geoference latitude #{a.latitude} do not match")
    end
    if (a.longitude.to_f != d_long)
      soft_validations.add(
        :base,
        "Verbatim longitude #{verbatim_longitude}: (#{d_long}) and point geoference longitude #{a.longitude} do not match")
    end
  end
end

#sv_minimally_check_for_a_labelObject (protected)



1204
1205
1206
1207
1208
1209
# File 'app/models/collecting_event.rb', line 1204

def sv_minimally_check_for_a_label
  [:verbatim_label, :print_label, :document_label, :field_notes].each do |v|
    return true if self.send(v).present?
  end
  soft_validations.add(:base, 'At least one label type, or field notes, should be provided.')
end

#sv_missing_geographic_areaObject (protected)



1200
1201
1202
# File 'app/models/collecting_event.rb', line 1200

def sv_missing_geographic_area
  soft_validations.add(:geographic_area_id, 'Geographic area is missing') if geographic_area_id.nil? && !georeferences.any?
end

#sv_missing_georeferenceObject (protected)



1179
1180
1181
# File 'app/models/collecting_event.rb', line 1179

def sv_missing_georeference
  soft_validations.add(:base, 'Georeference is missing') unless georeferences.any?
end

#sv_verbatim_uncertainty_formatObject (protected)



1211
1212
1213
1214
1215
1216
1217
1218
1219
# File 'app/models/collecting_event.rb', line 1211

def sv_verbatim_uncertainty_format
  begin
    if verbatim_geolocation_uncertainty.present? && verbatim_geolocation_uncertainty.to_unit
      return true
    end
  rescue ArgumentError
    soft_validations.add(:verbatim_geolocation_uncertainty, 'appears to be malformed, has no units?')
  end
end

#time_endString

Returns like 00, 00:00, or 00:00:00.

Returns:

  • (String)

    like 00, 00:00, or 00:00:00



552
553
554
# File 'app/models/collecting_event.rb', line 552

def time_end
  Utilities::Dates.format_to_hours_minutes_seconds(time_end_hour, time_end_minute, time_end_second)
end

#time_rangeArray

Returns time_start and end if provided.

Returns:

  • (Array)

    time_start and end if provided



558
559
560
# File 'app/models/collecting_event.rb', line 558

def time_range
  [time_start, time_end].compact
end

#time_startString

Returns like 00, 00:00, or 00:00:00.

Returns:

  • (String)

    like 00, 00:00, or 00:00:00



546
547
548
# File 'app/models/collecting_event.rb', line 546

def time_start
  Utilities::Dates.format_to_hours_minutes_seconds(time_start_hour, time_start_minute, time_start_second)
end

#to_geo_json_featureGeoJSON::Feature

TODO: DRY with helper methods, these are now outdated approaches

Returns:

  • (GeoJSON::Feature)

    the first geographic item of the first georeference on this collecting event



905
906
907
908
909
910
911
912
913
914
915
916
# File 'app/models/collecting_event.rb', line 905

def to_geo_json_feature
  # !! avoid loading the whole geographic item, just grab the bits we need:
  # self.georeferences(true)  # do this to
  to_simple_json_feature.merge({
    'properties' => {
      'collecting_event' => {
        'id'  => self.id,
        'tag' => "Collecting event #{self.id}."
      }
    }
  })
end

#to_simple_json_featureObject

TODO: DRY with helper methods, these are now outdated approaches

i.e. geographic_areas_geogrpahic_items.where( gaz = 'some string')


920
921
922
923
924
925
926
927
928
929
930
931
932
# File 'app/models/collecting_event.rb', line 920

def to_simple_json_feature
  base = {
    'type'       => 'Feature',
    'properties' => {}
  }

  if geographic_items.any?
    geo_item_id      = geographic_items.select(:id).first.id
    query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json"
    base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json)
  end
  base
end

#verbatim_map_center(delta_z = 0.0) ⇒ RGeo::Geographic::ProjectedPointImpl?

Returns for the verbatim latitude/longitude only.

Parameters:

  • delta_z, (Float)

    will be used to fill in the z coordinate of the point

Returns:

  • (RGeo::Geographic::ProjectedPointImpl, nil)

    for the verbatim latitude/longitude only



938
939
940
941
942
943
944
945
946
947
948
# File 'app/models/collecting_event.rb', line 938

def verbatim_map_center(delta_z = 0.0)
  retval = nil
  unless verbatim_latitude.blank? or verbatim_longitude.blank?
    lat  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_latitude.to_s)
    long = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(verbatim_longitude.to_s)
    elev = Utilities::Geo.distance_in_meters(verbatim_elevation.to_s).to_f
    delta_z = elev unless elev == 0.0 # Meh, BAD! must be nil
    retval  = Gis::FACTORY.point(long, lat, delta_z)
  end
  retval
end