001/*
002 * SPDX-License-Identifier: Apache-2.0
003 *
004 * Copyright 2025-2026 The Enola <https://enola.dev> Authors
005 *
006 * Licensed under the Apache License, Version 2.0 (the "License");
007 * you may not use this file except in compliance with the License.
008 * You may obtain a copy of the License at
009 *
010 *     https://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package dev.enola.common.convert;
019
020import com.google.common.collect.ImmutableList;
021
022import dev.enola.common.locale.LocaleSupplier;
023import dev.enola.common.locale.LocaleSupplierTLC;
024import dev.enola.common.time.ZoneIdSupplier;
025import dev.enola.common.time.ZoneIdSupplierTLC;
026
027import org.jspecify.annotations.Nullable;
028
029import java.time.Instant;
030import java.time.ZonedDateTime;
031import java.time.format.DateTimeFormatter;
032import java.time.format.DateTimeParseException;
033import java.time.format.FormatStyle;
034import java.time.format.ResolverStyle;
035import java.time.temporal.TemporalAccessor;
036import java.util.Locale;
037
038public class TemporalAccessorToStringConverter<T extends TemporalAccessor>
039        implements BiConverter<T, String>, ObjectToStringBiConverter<T> {
040
041    // TODO Factor common.convert unrelated parts of this out into common.time
042
043    // TODO Test (and likely fix resulting bugs from) other TemporalAccessor than Instant
044
045    public static final ObjectToStringBiConverter<Instant> INSTANT =
046            new TemporalAccessorToStringConverter<>();
047
048    private final String INSTANT_MIN_TEXT = Instant.MIN.toString();
049    private final String INSTANT_MAX_TEXT = Instant.MAX.toString();
050
051    private final LocaleSupplier localeSupplier;
052    private final ZoneIdSupplier timezoneSupplier;
053    private final ImmutableList<DateTimeFormatter> formatters;
054
055    public TemporalAccessorToStringConverter(
056            LocaleSupplier localeSupplier,
057            ZoneIdSupplier timezoneSupplier,
058            Iterable<DateTimeFormatter> formatters) {
059        this.localeSupplier = localeSupplier;
060        this.timezoneSupplier = timezoneSupplier;
061        this.formatters = ImmutableList.copyOf(formatters);
062    }
063
064    public TemporalAccessorToStringConverter(
065            LocaleSupplier localeSupplier, ZoneIdSupplier timezoneSupplier) {
066        this(
067                localeSupplier,
068                timezoneSupplier,
069                ImmutableList.of(
070                        DateTimeFormatter.ISO_INSTANT,
071                        DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL),
072                        DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG),
073                        DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM),
074                        DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT),
075                        // TODO This seems stupid - is there really no better way to do this?!
076                        DateTimeFormatter.ofPattern("MMMM d, yyyy, h:mm:ss a z"),
077                        DateTimeFormatter.ofPattern("dd. MMMM yyyy, HH:mm:ss XXX"),
078                        DateTimeFormatter.ofPattern("dd. MMMM yyyy, HH:mm:ss z")
079                        // ...
080                        ));
081    }
082
083    public TemporalAccessorToStringConverter() {
084        // NOT this(LocaleSupplierTLC.JVM_DEFAULT, TimezoneSupplierTLC.JVM_DEFAULT);
085        // but intentionally use something stable which does not change, for unflaky tests;
086        // this is fine, because real applications (CLI, webapps, etc.) should always have
087        // another Locale and Timezone set in the TLC anyways.
088        this(LocaleSupplierTLC.ROOT, ZoneIdSupplierTLC.UTC);
089    }
090
091    @Override
092    public @Nullable String convertTo(@Nullable T input) throws ConversionException {
093        if (input == null) return null;
094        TemporalAccessor temporalAccessor = input;
095
096        // Special handling of MIN & MAX using the default ISO_INSTANT DateTimeFormatter
097        if (input.equals(Instant.MIN)) return INSTANT_MIN_TEXT;
098        if (input.equals(Instant.MAX)) return INSTANT_MAX_TEXT;
099
100        var tz = timezoneSupplier.get();
101        if (temporalAccessor instanceof Instant instant) {
102            temporalAccessor = instant.atZone(tz);
103        }
104
105        DateTimeFormatter formatter;
106        var locale = localeSupplier.get();
107        if (locale.equals(Locale.ROOT)) formatter = DateTimeFormatter.ISO_INSTANT;
108        else
109            formatter =
110                    DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG)
111                            .withLocale(locale)
112                            .withZone(tz);
113        return formatter.format(temporalAccessor);
114    }
115
116    @Override
117    @SuppressWarnings("unchecked") // Java generics are stupid
118    public @Nullable T convertFrom(@Nullable String text) throws ConversionException {
119        if (text == null) return null;
120
121        // Special handling of MIN & MAX using the default ISO_INSTANT DateTimeFormatter
122        if (text.equals(INSTANT_MIN_TEXT)) return (T) Instant.MIN;
123        if (text.equals(INSTANT_MAX_TEXT)) return (T) Instant.MAX;
124
125        try {
126            return (T) parseTryingPatterns(text);
127        } catch (DateTimeParseException e) {
128            throw new ConversionException(text, e);
129        }
130    }
131
132    private TemporalAccessor parseTryingPatterns(String text) {
133        for (var formatter : formatters) {
134            try {
135                return parse(text, formatter);
136            } catch (DateTimeParseException e) {
137                // throw e; // For easier DEBUGGING (only)
138                // Ignore
139            }
140        }
141        throw new DateTimeParseException(
142                "Could not parse with any of the registered DateTimeFormatter", text, 0);
143    }
144
145    private TemporalAccessor parse(String text, DateTimeFormatter formatter) {
146        var localizedZonedFormatter =
147                formatter
148                        .withLocale(localeSupplier.get())
149                        .withZone(timezoneSupplier.get())
150                        .withResolverStyle(ResolverStyle.SMART); // TODO STRICT ?
151        // NB: Must use ZonedDateTime instead of OffsetDateTime, because the former parses both
152        // "-06:00" (offset) as well as "MESZ" (zone) whereas the latter only handles offsets.
153        var offsetDateTime = ZonedDateTime.parse(text, localizedZonedFormatter);
154        return offsetDateTime.toInstant();
155    }
156}