/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to you under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.logging.log4j.layout.template.json.util;

import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Locale;
import java.util.Objects;
import java.util.TimeZone;
import org.apache.logging.log4j.core.time.Instant;
import org.apache.logging.log4j.core.time.MutableInstant;
import org.apache.logging.log4j.core.util.datetime.FastDateFormat;
import org.apache.logging.log4j.core.util.datetime.FixedDateFormat;
import org.apache.logging.log4j.status.StatusLogger;
import org.apache.logging.log4j.util.Strings;

/**
 * A composite {@link Instant} formatter trying to employ either
 * {@link FixedDateFormat}, {@link FastDateFormat}, or {@link DateTimeFormatter}
 * in the given order due to performance reasons.
 * <p>
 * Note that {@link FixedDateFormat} and {@link FastDateFormat} only support
 * millisecond precision. If the pattern asks for a higher precision,
 * {@link DateTimeFormatter} will be employed, which is significantly slower.
 */
public final class InstantFormatter {

    private static final StatusLogger LOGGER = StatusLogger.getLogger();

    /**
     * The list of formatter factories in decreasing efficiency order.
     */
    private static final FormatterFactory[] FORMATTER_FACTORIES = {
        new Log4jFixedFormatterFactory(), new Log4jFastFormatterFactory(), new JavaDateTimeFormatterFactory()
    };

    private final Formatter formatter;

    private InstantFormatter(final Builder builder) {
        this.formatter = Arrays.stream(FORMATTER_FACTORIES)
                .map(formatterFactory -> {
                    try {
                        return formatterFactory.createIfSupported(
                                builder.getPattern(), builder.getLocale(), builder.getTimeZone());
                    } catch (final Exception error) {
                        LOGGER.warn("skipping the failed formatter factory \"{}\"", formatterFactory, error);
                        return null;
                    }
                })
                .filter(Objects::nonNull)
                .findFirst()
                .orElseThrow(() -> new AssertionError("could not find a matching formatter"));
    }

    public String format(final Instant instant) {
        Objects.requireNonNull(instant, "instant");
        final StringBuilder stringBuilder = new StringBuilder();
        formatter.format(instant, stringBuilder);
        return stringBuilder.toString();
    }

    public void format(final Instant instant, final StringBuilder stringBuilder) {
        Objects.requireNonNull(instant, "instant");
        Objects.requireNonNull(stringBuilder, "stringBuilder");
        formatter.format(instant, stringBuilder);
    }

    /**
     * Checks if the given {@link Instant}s are equal from the point of view of
     * the employed formatter.
     * <p>
     * This method should be preferred over {@link Instant#equals(Object)}. For
     * instance, {@link FixedDateFormat} and {@link FastDateFormat} discard
     * nanoseconds, hence, from their point of view, two different
     * {@link Instant}s are equal if they match up to millisecond precision.
     */
    public boolean isInstantMatching(final Instant instant1, final Instant instant2) {
        return formatter.isInstantMatching(instant1, instant2);
    }

    public Class<?> getInternalImplementationClass() {
        return formatter.getInternalImplementationClass();
    }

    public static Builder newBuilder() {
        return new Builder();
    }

    public static final class Builder {

        private String pattern;

        private Locale locale = Locale.getDefault();

        private TimeZone timeZone = TimeZone.getDefault();

        private Builder() {}

        public String getPattern() {
            return pattern;
        }

        public Builder setPattern(final String pattern) {
            this.pattern = pattern;
            return this;
        }

        public Locale getLocale() {
            return locale;
        }

        public Builder setLocale(final Locale locale) {
            this.locale = locale;
            return this;
        }

        public TimeZone getTimeZone() {
            return timeZone;
        }

        public Builder setTimeZone(final TimeZone timeZone) {
            this.timeZone = timeZone;
            return this;
        }

        public InstantFormatter build() {
            validate();
            return new InstantFormatter(this);
        }

        private void validate() {
            if (Strings.isBlank(pattern)) {
                throw new IllegalArgumentException("blank pattern");
            }
            Objects.requireNonNull(locale, "locale");
            Objects.requireNonNull(timeZone, "timeZone");
        }
    }

    private interface FormatterFactory {

        Formatter createIfSupported(String pattern, Locale locale, TimeZone timeZone);
    }

    private interface Formatter {

        Class<?> getInternalImplementationClass();

        void format(Instant instant, StringBuilder stringBuilder);

        boolean isInstantMatching(Instant instant1, Instant instant2);
    }

    private static final class JavaDateTimeFormatterFactory implements FormatterFactory {

        @Override
        public Formatter createIfSupported(final String pattern, final Locale locale, final TimeZone timeZone) {
            return new JavaDateTimeFormatter(pattern, locale, timeZone);
        }
    }

    private static final class JavaDateTimeFormatter implements Formatter {

        private final DateTimeFormatter formatter;

        private final MutableInstant mutableInstant;

        private JavaDateTimeFormatter(final String pattern, final Locale locale, final TimeZone timeZone) {
            this.formatter =
                    DateTimeFormatter.ofPattern(pattern).withLocale(locale).withZone(timeZone.toZoneId());
            this.mutableInstant = new MutableInstant();
        }

        @Override
        public Class<?> getInternalImplementationClass() {
            return DateTimeFormatter.class;
        }

        @Override
        public void format(final Instant instant, final StringBuilder stringBuilder) {
            if (instant instanceof MutableInstant) {
                formatMutableInstant((MutableInstant) instant, stringBuilder);
            } else {
                formatInstant(instant, stringBuilder);
            }
        }

        private void formatMutableInstant(final MutableInstant instant, final StringBuilder stringBuilder) {
            formatter.formatTo(instant, stringBuilder);
        }

        private void formatInstant(final Instant instant, final StringBuilder stringBuilder) {
            mutableInstant.initFrom(instant);
            formatMutableInstant(mutableInstant, stringBuilder);
        }

        @Override
        public boolean isInstantMatching(final Instant instant1, final Instant instant2) {
            return instant1.getEpochSecond() == instant2.getEpochSecond()
                    && instant1.getNanoOfSecond() == instant2.getNanoOfSecond();
        }
    }

    private static final class Log4jFastFormatterFactory implements FormatterFactory {

        @Override
        public Formatter createIfSupported(final String pattern, final Locale locale, final TimeZone timeZone) {
            final Log4jFastFormatter formatter = new Log4jFastFormatter(pattern, locale, timeZone);
            final boolean patternSupported = patternSupported(pattern, locale, timeZone, formatter);
            return patternSupported ? formatter : null;
        }
    }

    private static final class Log4jFastFormatter implements Formatter {

        private final FastDateFormat formatter;

        private final Calendar calendar;

        private Log4jFastFormatter(final String pattern, final Locale locale, final TimeZone timeZone) {
            this.formatter = FastDateFormat.getInstance(pattern, timeZone, locale);
            this.calendar = Calendar.getInstance(timeZone, locale);
        }

        @Override
        public Class<?> getInternalImplementationClass() {
            return FastDateFormat.class;
        }

        @Override
        public void format(final Instant instant, final StringBuilder stringBuilder) {
            calendar.setTimeInMillis(instant.getEpochMillisecond());
            formatter.format(calendar, stringBuilder);
        }

        @Override
        public boolean isInstantMatching(final Instant instant1, final Instant instant2) {
            return instant1.getEpochMillisecond() == instant2.getEpochMillisecond();
        }
    }

    private static final class Log4jFixedFormatterFactory implements FormatterFactory {

        @Override
        public Formatter createIfSupported(final String pattern, final Locale locale, final TimeZone timeZone) {
            final FixedDateFormat internalFormatter = FixedDateFormat.createIfSupported(pattern, timeZone.getID());
            if (internalFormatter == null) {
                return null;
            }
            final Log4jFixedFormatter formatter = new Log4jFixedFormatter(internalFormatter);
            final boolean patternSupported = patternSupported(pattern, locale, timeZone, formatter);
            return patternSupported ? formatter : null;
        }
    }

    private static final class Log4jFixedFormatter implements Formatter {

        private final FixedDateFormat formatter;

        private final char[] buffer;

        private Log4jFixedFormatter(final FixedDateFormat formatter) {
            this.formatter = formatter;
            // double size for locales with lengthy DateFormatSymbols
            this.buffer = new char[formatter.getLength() << 1];
        }

        @Override
        public Class<?> getInternalImplementationClass() {
            return FixedDateFormat.class;
        }

        @Override
        public void format(final Instant instant, final StringBuilder stringBuilder) {
            final int length = formatter.formatInstant(instant, buffer, 0);
            stringBuilder.append(buffer, 0, length);
        }

        @Override
        public boolean isInstantMatching(final Instant instant1, final Instant instant2) {
            return formatter.isEquivalent(
                    instant1.getEpochSecond(),
                    instant1.getNanoOfSecond(),
                    instant2.getEpochSecond(),
                    instant2.getNanoOfSecond());
        }
    }

    /**
     * Checks if the provided formatter output matches with the one generated by
     * {@link DateTimeFormatter}.
     */
    private static boolean patternSupported(
            final String pattern, final Locale locale, final TimeZone timeZone, final Formatter formatter) {
        final DateTimeFormatter javaFormatter =
                DateTimeFormatter.ofPattern(pattern).withLocale(locale).withZone(timeZone.toZoneId());
        final MutableInstant instant = new MutableInstant();
        instant.initFromEpochSecond(
                // 2021-05-17 21:41:10
                1621280470,
                // Using the highest nanosecond precision possible to
                // differentiate formatters only supporting millisecond
                // precision.
                123_456_789);
        final String expectedFormat = javaFormatter.format(instant);
        final StringBuilder stringBuilder = new StringBuilder();
        formatter.format(instant, stringBuilder);
        final String actualFormat = stringBuilder.toString();
        return expectedFormat.equals(actualFormat);
    }
}
