001package org.gbif.common.parsers.date;
002
003import lombok.Builder;
004import lombok.extern.slf4j.Slf4j;
005import org.apache.commons.lang3.StringUtils;
006import org.gbif.api.util.IsoDateInterval;
007import org.gbif.api.vocabulary.OccurrenceIssue;
008import org.gbif.common.parsers.core.OccurrenceParseResult;
009import org.gbif.common.parsers.core.ParseResult;
010import org.gbif.common.parsers.utils.DelimiterUtils;
011
012import java.io.Serializable;
013import java.time.LocalDate;
014import java.time.LocalDateTime;
015import java.time.OffsetDateTime;
016import java.time.Year;
017import java.time.YearMonth;
018import java.time.ZonedDateTime;
019import java.time.temporal.ChronoField;
020import java.time.temporal.ChronoUnit;
021import java.time.temporal.Temporal;
022import java.time.temporal.TemporalAccessor;
023import java.time.temporal.TemporalUnit;
024import java.util.HashSet;
025import java.util.Optional;
026import java.util.Set;
027
028@Slf4j
029public class TemporalRangeParser implements Serializable {
030
031  private final MultiinputTemporalParser temporalParser;
032
033  @Builder(buildMethodName = "create")
034  private TemporalRangeParser(MultiinputTemporalParser temporalParser) {
035    if (temporalParser != null) {
036      this.temporalParser = temporalParser;
037    } else {
038      this.temporalParser = MultiinputTemporalParser.create();
039    }
040  }
041
042  public OccurrenceParseResult<IsoDateInterval> parse(String dateRange) {
043    return parse(null, null, null, dateRange, null, null);
044  }
045
046  public OccurrenceParseResult<IsoDateInterval> parse(String year, String month, String day, String dateRange) {
047    return parse(year, month, day, dateRange, null, null);
048  }
049
050  public OccurrenceParseResult<IsoDateInterval> parse(
051      String year,
052      String month,
053      String day,
054      String dateRange,
055      String startDayOfYear,
056      String endDayOfYear) {
057
058    Set<OccurrenceIssue> issues = new HashSet<>();
059
060    try {
061      // Even a single date will be split to two
062      final String[] rawPeriod = DelimiterUtils.splitPeriod(dateRange);
063
064      Temporal from;
065      Temporal to;
066
067      // If eventDate and year are present
068      if (StringUtils.isNotBlank(dateRange) && StringUtils.isNotBlank(year)) {
069        OccurrenceParseResult<TemporalAccessor> dateRangeOnlyStart = temporalParser.parseRecordedDate(null, null, null, rawPeriod[0], null);
070        OccurrenceParseResult<TemporalAccessor> dateRangeOnlyEnd = temporalParser.parseRecordedDate(null, null, null, rawPeriod[1], null);
071        OccurrenceParseResult<TemporalAccessor> ymdOnly = temporalParser.parseRecordedDate(year, month, day, null, null);
072
073        if (ymdOnly.isSuccessful()) {
074          if (dateRangeOnlyStart.isSuccessful() && dateRangeOnlyEnd.isSuccessful()) {
075            // If eventDate is a multi-day range, with at least day precision, and year+month+day are set, we must test
076            // whether year+month+day falls within this range.
077            if (StringUtils.isNotBlank(month) && StringUtils.isNotBlank(day)) {
078              if (dateRangeOnlyStart.getPayload().isSupported(ChronoField.DAY_OF_YEAR)
079                && dateRangeOnlyEnd.getPayload().isSupported(ChronoField.DAY_OF_YEAR)
080                && ymdOnly.getPayload().isSupported(ChronoField.DAY_OF_YEAR)) {
081                if (TemporalAccessorUtils.withinRange(dateRangeOnlyStart.getPayload(), dateRangeOnlyEnd.getPayload(), ymdOnly.getPayload()) ||
082                  // Also if the range is backwards
083                  TemporalAccessorUtils.withinRange(dateRangeOnlyEnd.getPayload(), dateRangeOnlyStart.getPayload(), ymdOnly.getPayload())) {
084                  // Then we can just check the startDayOfYear and endDayOfYear fields match.
085                  from = parseAndSet(null, null, null, rawPeriod[0], startDayOfYear, issues);
086                  to = parseAndSet(null, null, null, rawPeriod[1], endDayOfYear, issues);
087                  log.trace("Range {}|{}|{}|{}|{}|{} succeeds with ymd within range {}→{}", year, month, day, dateRange, startDayOfYear, endDayOfYear, from, to);
088                  return OccurrenceParseResult.success(ParseResult.CONFIDENCE.DEFINITE, finalChecks(from, to, issues), issues);
089                }
090              }
091            }
092          }
093
094          // If that didn't work, test whether year+month+day are set according to the constant parts of eventDate.
095          Optional<TemporalAccessor> dateRangeConstant = TemporalAccessorUtils.nonConflictingDateParts(dateRangeOnlyStart.getPayload(), dateRangeOnlyEnd.getPayload(), null);
096          if (dateRangeConstant.isPresent() && ymdOnly.getPayload().equals(dateRangeConstant.get())) {
097            // Then we can just check the startDayOfYear and endDayOfYear fields match.
098            from = parseAndSet(null, null, null, rawPeriod[0], startDayOfYear, issues);
099            to = parseAndSet(null, null, null, rawPeriod[1], endDayOfYear, issues);
100            log.trace("Range {}|{}|{}|{}|{}|{} succeeds with correct ymd parts {}→{}", year, month, day, dateRange, startDayOfYear, endDayOfYear, from, to);
101            return OccurrenceParseResult.success(ParseResult.CONFIDENCE.DEFINITE, finalChecks(from, to, issues), issues);
102          }
103        }
104      }
105
106      // Otherwise, we will reduce the precision of the given dates until they all agree.
107
108      // Year+month+day, first part of eventDate, and startDayOfYear to the best we can get.
109      from = parseAndSet(year, month, day, rawPeriod[0], startDayOfYear, issues);
110      // Year+month+day, second part of eventDate, and endDayOfYear to the best we can get.
111      to = parseAndSet(year, month, day, rawPeriod[1], endDayOfYear, issues);
112      log.trace("Range {}|{}|{}|{}|{}|{} parsed to {}→{}", year, month, day, dateRange, startDayOfYear, endDayOfYear, from, to);
113
114      // Neither could be parsed, or else the year is outside the range.
115      if (from == null && to == null) {
116        log.debug("Range {}|{}|{}|{}|{}|{} could not be parsed", year, month, day, dateRange, startDayOfYear, endDayOfYear);
117        return OccurrenceParseResult.fail(issues);
118      }
119
120      // The year is outside one part of the date range.
121      if (from != null ^ to != null) {
122        log.debug("Range {}|{}|{}|{}|{}|{} fails due to missing start xor end {}→{}", year, month, day, dateRange, startDayOfYear, endDayOfYear, from, to);
123        issues.add(OccurrenceIssue.RECORDED_DATE_MISMATCH);
124        return OccurrenceParseResult.fail(issues);
125      }
126
127      // Apply final checks
128      log.trace("Range {}|{}|{}|{}|{}|{} succeeds {}→{}", year, month, day, dateRange, startDayOfYear, endDayOfYear, from, to);
129      return OccurrenceParseResult.success(ParseResult.CONFIDENCE.DEFINITE, finalChecks(from, to, issues), issues);
130    } catch (Exception e) {
131      log.error("Exception when parsing dates: {}, {}, {}, {}, {}, {}, {}", year, month, day, dateRange, startDayOfYear, endDayOfYear);
132      log.error("Exception is "+e.getMessage(), e);
133      issues.add(OccurrenceIssue.RECORDED_DATE_INVALID);
134      issues.add(OccurrenceIssue.INTERPRETATION_ERROR);
135      return OccurrenceParseResult.fail(issues);
136    }
137  }
138
139  private Temporal parseAndSet(
140      String year,
141      String month,
142      String day,
143      String rawDate,
144      String dayOfYear,
145      Set<OccurrenceIssue> issues) {
146    OccurrenceParseResult<TemporalAccessor> result =
147        temporalParser.parseRecordedDate(year, month, day, rawDate, dayOfYear);
148    issues.addAll(result.getIssues());
149    if (result.isSuccessful()) {
150      return (Temporal) result.getPayload();
151    } else {
152      return null;
153    }
154  }
155
156  /** Compare dates and returns difference between FROM and TO dates in milliseconds */
157  private static long getRangeDiff(Temporal from, Temporal to) {
158    if (from == null || to == null) {
159      return 1L;
160    }
161    TemporalUnit unit = null;
162    if (from instanceof Year) {
163      unit = ChronoUnit.YEARS;
164    } else if (from instanceof YearMonth) {
165      unit = ChronoUnit.MONTHS;
166    } else if (from instanceof LocalDate) {
167      unit = ChronoUnit.DAYS;
168    } else if (from instanceof LocalDateTime
169        || from instanceof OffsetDateTime
170        || from instanceof ZonedDateTime) {
171      unit = ChronoUnit.SECONDS;
172    }
173    return from.until(to, unit);
174  }
175
176  /**
177   * Make sure the range is the correct way around, has equal resolutions, and matching time zones.
178   */
179  private IsoDateInterval finalChecks(Temporal from, Temporal to, Set<OccurrenceIssue> issues) {
180    if (from != null && to != null) {
181      // If the dateRange has different resolutions on either side, truncate it.
182      if (TemporalAccessorUtils.resolutionToSeconds(from) != TemporalAccessorUtils.resolutionToSeconds(to)) {
183        log.trace("Resolutions don't match, truncate.");
184        int requiredResolution = Math.min(TemporalAccessorUtils.resolutionToSeconds(from), TemporalAccessorUtils.resolutionToSeconds(to));
185        from = TemporalAccessorUtils.limitToResolution(from, requiredResolution);
186        to = TemporalAccessorUtils.limitToResolution(to, requiredResolution);
187        issues.add(OccurrenceIssue.RECORDED_DATE_MISMATCH);
188      }
189
190      // If one side has a time zone and the other doesn't, remove it.
191      if (from.isSupported(ChronoField.OFFSET_SECONDS) ^ to.isSupported(ChronoField.OFFSET_SECONDS)) {
192        from = from.query(LocalDateTime::from);
193        to = to.query(LocalDateTime::from);
194      }
195
196      // Reverse order if needed
197      if (from.getClass() == to.getClass()) {
198        long rangeDiff = getRangeDiff(from, to);
199        if (rangeDiff < 0) {
200          log.trace("Range was inverted.");
201          issues.add(OccurrenceIssue.RECORDED_DATE_INVALID);
202          return new IsoDateInterval(to, from);
203        }
204      } else {
205        issues.add(OccurrenceIssue.RECORDED_DATE_UNLIKELY);
206      }
207    }
208    return new IsoDateInterval(from, to);
209  }
210}