1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.core.metapath.item.atomic;
7   
8   import java.time.LocalDate;
9   import java.time.LocalDateTime;
10  import java.time.LocalTime;
11  import java.time.OffsetTime;
12  import java.time.ZoneId;
13  import java.time.ZoneOffset;
14  import java.time.ZonedDateTime;
15  
16  import dev.metaschema.core.datatype.adapter.MetaschemaDataTypeProvider;
17  import dev.metaschema.core.datatype.object.AmbiguousDateTime;
18  import dev.metaschema.core.metapath.DynamicContext;
19  import dev.metaschema.core.metapath.function.DateTimeFunctionException;
20  import dev.metaschema.core.metapath.function.InvalidValueForCastFunctionException;
21  import dev.metaschema.core.metapath.item.atomic.impl.DateTimeWithoutTimeZoneItemImpl;
22  import dev.metaschema.core.metapath.type.IAtomicOrUnionType;
23  import dev.metaschema.core.metapath.type.InvalidTypeMetapathException;
24  import dev.metaschema.core.util.ObjectUtils;
25  import edu.umd.cs.findbugs.annotations.NonNull;
26  import edu.umd.cs.findbugs.annotations.Nullable;
27  
28  /**
29   * An atomic Metapath item representing a date/time value in the Metapath
30   * system.
31   * <p>
32   * This interface provides functionality for handling date/time values with and
33   * without time zone information, supporting parsing, casting, and comparison
34   * operations. It works in conjunction with {@link AmbiguousDateTime} to
35   * properly handle time zone ambiguity.
36   */
37  public interface IDateTimeItem extends ICalendarTemporalItem {
38    /**
39     * Get the type information for this item.
40     *
41     * @return the type information
42     */
43    @NonNull
44    static IAtomicOrUnionType<IDateTimeItem> type() {
45      return MetaschemaDataTypeProvider.DATE_TIME.getItemType();
46    }
47  
48    @Override
49    default IAtomicOrUnionType<? extends IDateTimeItem> getType() {
50      return type();
51    }
52  
53    /**
54     * Construct a new date/time item using the provided string {@code value}.
55     *
56     * @param value
57     *          a string representing a date/time
58     * @return the new item
59     */
60    @NonNull
61    static IDateTimeItem valueOf(@NonNull String value) {
62      try {
63        return valueOf(MetaschemaDataTypeProvider.DATE_TIME.parse(value));
64      } catch (IllegalArgumentException ex) {
65        throw new InvalidTypeMetapathException(
66            null,
67            String.format("Invalid date/time value '%s'. %s",
68                value,
69                ex.getLocalizedMessage()),
70            ex);
71      }
72    }
73  
74    /**
75     * Get a date/time item based on the provided date and time item values.
76     *
77     * @param date
78     *          the date portion of the date/time
79     * @param time
80     *          the time portion of the date/time
81     * @return the date/time item
82     */
83    @SuppressWarnings("PMD.CyclomaticComplexity")
84    @NonNull
85    static IDateTimeItem valueOf(@NonNull IDateItem date, @NonNull ITimeItem time) {
86      ZonedDateTime zdtDate = ObjectUtils.notNull(date.asZonedDateTime());
87      ZoneId tzDate = date.hasTimezone() ? zdtDate.getZone() : null;
88      OffsetTime zdtTime = ObjectUtils.notNull(time.asOffsetTime());
89      ZoneId tzTime = time.hasTimezone() ? zdtTime.getOffset() : null;
90  
91      if (tzDate != null && tzTime != null && !tzDate.equals(tzTime)) {
92        throw new InvalidTypeMetapathException(
93            null,
94            String.format("The date and time values do not have the same timezone value. date='%s', time='%s'",
95                tzDate.toString(),
96                tzTime.toString()));
97      }
98  
99      // either both have the same timezone, both are null, or only one has a timezone
100     ZoneId zone = tzDate == null
101         ? tzTime == null ? null : tzTime
102         : tzDate;
103 
104     return valueOf(
105         ObjectUtils.notNull(ZonedDateTime.of(
106             zdtDate.toLocalDate(),
107             zdtTime.toLocalTime(),
108             zone == null ? ZoneOffset.UTC : zone)),
109         zone != null);
110   }
111 
112   /**
113    * Get the provided item as a date/time item.
114    *
115    * @param item
116    *          the item to convert to a date/time
117    * @return the provided value as a date/time
118    */
119   @NonNull
120   static IDateTimeItem valueOf(@NonNull ICalendarTemporalItem item) {
121     return item instanceof IDateTimeItem
122         ? (IDateTimeItem) item
123         : valueOf(item.asZonedDateTime(), item.hasTimezone());
124   }
125 
126   /**
127    * Construct a new date/time item using the provided {@code value}.
128    * <p>
129    * This method handles recording if an explicit timezone was provided using the
130    * {@code hasTimeZone} parameter. The {@link AmbiguousDateTime#hasTimeZone()}
131    * method can be called to determine if timezone information is present.
132    *
133    * @param value
134    *          a date/time, without time zone information
135    * @param hasTimeZone
136    *          {@code true} if the date/time is intended to have an associated time
137    *          zone or {@code false} otherwise
138    * @return the new item
139    * @see AmbiguousDateTime for more details on timezone handling
140    */
141   @NonNull
142   static IDateTimeItem valueOf(@NonNull ZonedDateTime value, boolean hasTimeZone) {
143     return hasTimeZone
144         ? IDateTimeWithTimeZoneItem.valueOf(value)
145         : valueOf(new AmbiguousDateTime(value, false));
146   }
147 
148   /**
149    * Construct a new date/time item using the provided local time {@code value}.
150    * <p>
151    * The timezone is marked as ambiguous, meaning the
152    * {@link AmbiguousDateTime#hasTimeZone()} method will return a result of
153    * {@code false}.
154    *
155    * @param value
156    *          the local time value to use
157    * @return the new item
158    * @see AmbiguousDateTime for more details on timezone handling
159    */
160   @NonNull
161   static IDateTimeItem valueOf(@NonNull LocalDateTime value) {
162     return valueOf(new AmbiguousDateTime(ObjectUtils.notNull(value.atZone(ZoneOffset.UTC)), false));
163   }
164 
165   /**
166    * Construct a new date/time item using the provided {@code value}.
167    * <p>
168    * This method handles recording if an explicit timezone was provided using the
169    * {@link AmbiguousDateTime}. The {@link AmbiguousDateTime#hasTimeZone()} method
170    * can be called to determine if timezone information is present.
171    *
172    * @param value
173    *          a date/time, without time zone information
174    * @return the new item
175    * @see AmbiguousDateTime for more details on timezone handling
176    */
177   @NonNull
178   static IDateTimeItem valueOf(@NonNull AmbiguousDateTime value) {
179     return value.hasTimeZone()
180         ? IDateTimeWithTimeZoneItem.valueOf(value.getValue())
181         : new DateTimeWithoutTimeZoneItemImpl(value);
182   }
183 
184   @Override
185   default boolean hasDate() {
186     return true;
187   }
188 
189   @Override
190   default boolean hasTime() {
191     return true;
192   }
193 
194   @Override
195   default int getYear() {
196     return asZonedDateTime().getYear();
197   }
198 
199   @Override
200   default int getMonth() {
201     return asZonedDateTime().getMonthValue();
202   }
203 
204   @Override
205   default int getDay() {
206     return asZonedDateTime().getDayOfMonth();
207   }
208 
209   @Override
210   default int getHour() {
211     return asZonedDateTime().getHour();
212   }
213 
214   @Override
215   default int getMinute() {
216     return asZonedDateTime().getMinute();
217   }
218 
219   @Override
220   default int getSecond() {
221     return asZonedDateTime().getSecond();
222   }
223 
224   @Override
225   default int getNano() {
226     return asZonedDateTime().getNano();
227   }
228 
229   /**
230    * Get the date as a {@link LocalDate}.
231    *
232    * @return the date
233    */
234   @NonNull
235   default LocalDateTime asLocalDateTime() {
236     return ObjectUtils.notNull(asZonedDateTime().toLocalDateTime());
237   }
238 
239   /**
240    * Get the date as a {@link LocalDate}.
241    *
242    * @return the date
243    */
244   @NonNull
245   default LocalDate asLocalDate() {
246     return ObjectUtils.notNull(asZonedDateTime().toLocalDate());
247   }
248 
249   /**
250    * Get the date/time as a {@link LocalTime}.
251    *
252    * @return the time
253    */
254   @NonNull
255   default LocalTime asLocalTime() {
256     return ObjectUtils.notNull(asZonedDateTime().toLocalTime());
257   }
258 
259   /**
260    * Get the date/time as an {@link OffsetTime}.
261    *
262    * @return the time
263    */
264   @NonNull
265   default OffsetTime asOffsetTime() {
266     return ObjectUtils.notNull(asZonedDateTime().toOffsetDateTime().toOffsetTime());
267   }
268 
269   /**
270    * Get the date/time as a date item.
271    *
272    * @return the date portion of this date/time
273    */
274   @NonNull
275   default IDateItem asDate() {
276     return IDateItem.valueOf(asZonedDateTime(), hasTimezone());
277   }
278 
279   /**
280    * Get the date/time as a time item.
281    *
282    * @return the time portion of this date/time
283    */
284   @NonNull
285   default ITimeItem asTime() {
286     return ITimeItem.valueOf(asOffsetTime(), hasTimezone());
287   }
288 
289   /**
290    * Get a date/time that has an explicit timezone.
291    * <p>
292    * If this date/time has a timezone, then this timezone is used. Otherwise, the
293    * implicit timezone is used from the dynamic context to create a new date/time.
294    *
295    * @param dynamicContext
296    *          the dynamic context used to get the implicit timezone
297    * @return the date/time with the timezone normalized using UTC-based timezone
298    */
299   @NonNull
300   default IDateTimeItem normalize(@NonNull DynamicContext dynamicContext) {
301     IDateTimeItem retval = hasTimezone()
302         ? this
303         : replaceTimezone(dynamicContext.getImplicitTimeZoneAsDayTimeDuration());
304     return valueOf(
305         ObjectUtils.notNull(retval.asZonedDateTime().withZoneSameInstant(ZoneOffset.UTC)),
306         true);
307   }
308 
309   /**
310    * Get this date/time in the UTC timezone.
311    *
312    * @return the date/time in UTC
313    */
314   default IDateTimeItem asDateTimeZ() {
315     return ZoneOffset.UTC.equals(getZoneOffset())
316         ? this
317         : valueOf(ObjectUtils.notNull(asZonedDateTime().withZoneSameLocal(ZoneOffset.UTC)), hasTimezone());
318   }
319 
320   /**
321    * Adjusts an xs:dateTime value to a specific timezone, or to no timezone at
322    * all.
323    * <p>
324    * This method does one of the following things based on the arguments.
325    * <ol>
326    * <li>If the provided offset is {@code null} and the provided date/time value
327    * has a timezone, the timezone is maked absent.
328    * <li>If the provided offset is {@code null} and the provided date/time value
329    * has an absent timezone, the date/time value is returned.
330    * <li>If the provided offset is not {@code null} and the provided date/time
331    * value has an absent timezone, the date/time value is returned with the new
332    * timezone applied.
333    * <li>Otherwise, the provided timezone is applied to the date/time value
334    * adjusting the time instant.
335    * </ol>
336    * <p>
337    * Implements the XPath 3.1 <a href=
338    * "https://www.w3.org/TR/xpath-functions-31/#func-adjust-dateTime-to-timezone">fn:adjust-dateTime-to-timezone</a>
339    * function.
340    *
341    * @param offset
342    *          the timezone offset to use or {@code null}
343    * @return the adjusted date/time value
344    * @throws DateTimeFunctionException
345    *           with code
346    *           {@link DateTimeFunctionException#INVALID_TIME_ZONE_VALUE_ERROR} if
347    *           the offset is &lt; -PT14H or &gt; PT14H
348    */
349   @Override
350   default IDateTimeItem replaceTimezone(@Nullable IDayTimeDurationItem offset) {
351     return offset == null
352         ? hasTimezone()
353             ? valueOf(ObjectUtils.notNull(asZonedDateTime().withZoneSameLocal(ZoneOffset.UTC)), false)
354             : this
355         : hasTimezone()
356             ? valueOf(
357                 ObjectUtils.notNull(asZonedDateTime().withZoneSameInstant(offset.asZoneOffset())),
358                 true)
359             : valueOf(
360                 ObjectUtils.notNull(asZonedDateTime().withZoneSameLocal(offset.asZoneOffset())),
361                 true);
362   }
363 
364   /**
365    * Cast the provided type to this item type.
366    *
367    * @param item
368    *          the item to cast
369    * @return the original item if it is already this type, otherwise a new item
370    *         cast to this type
371    * @throws InvalidValueForCastFunctionException
372    *           if the provided {@code item} cannot be cast to this type
373    */
374   @NonNull
375   static IDateTimeItem cast(@NonNull IAnyAtomicItem item) {
376     IDateTimeItem retval;
377     if (item instanceof IDateTimeItem) {
378       retval = (IDateTimeItem) item;
379     } else if (item instanceof IDateItem) {
380       IDateItem date = (IDateItem) item;
381       retval = valueOf(date.asZonedDateTime(), date.hasTimezone());
382     } else if (item instanceof IStringItem || item instanceof IUntypedAtomicItem) {
383       try {
384         retval = valueOf(item.asString());
385       } catch (IllegalStateException | InvalidTypeMetapathException ex) {
386         // asString can throw IllegalStateException exception
387         throw new InvalidValueForCastFunctionException(ex);
388       }
389     } else {
390       throw new InvalidValueForCastFunctionException(
391           String.format("unsupported item type '%s'", item.getClass().getName()));
392     }
393     return retval;
394   }
395 
396   @Override
397   default IDateTimeItem castAsType(IAnyAtomicItem item) {
398     return cast(item);
399   }
400 }