TimeFormatter.java

/*
 * Copyright (C) 2018 Alberto Irurueta Carro (alberto@irurueta.com)
 *
 * Licensed 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 com.irurueta.units;

import java.text.FieldPosition;
import java.text.MessageFormat;
import java.text.NumberFormat;
import java.text.ParseException;
import java.util.Locale;
import java.util.regex.Pattern;

/**
 * Formats and parses time value and unit.
 */
@SuppressWarnings("DuplicatedCode")
public class TimeFormatter extends MeasureFormatter<Time, TimeUnit> {

    /**
     * Flag indicating whether nanoseconds must be included when formatting a time
     * instance.
     */
    public static final int FORMAT_NANOSECONDS = 1;

    /**
     * Flag indicating whether microseconds must be included when formatting a time
     * instance.
     */
    public static final int FORMAT_MICROSECONDS = 1 << 1;

    /**
     * Flag indicating whether milliseconds must be included when formatting a time
     * instance.
     */
    public static final int FORMAT_MILLISECONDS = 1 << 2;

    /**
     * Flag indicating whether seconds must be included when formatting a time
     * instance.
     */
    public static final int FORMAT_SECONDS = 1 << 3;

    /**
     * Flag indicating whether minutes must be included when formatting a time
     * instance.
     */
    public static final int FORMAT_MINUTES = 1 << 4;

    /**
     * Flag indicating whether hours must be included when formatting a time
     * instance.
     */
    public static final int FORMAT_HOURS = 1 << 5;

    /**
     * Flag indicating whether days must be included when formatting a time
     * instance.
     */
    public static final int FORMAT_DAYS = 1 << 6;

    /**
     * Flag indicating whether weeks must be included when formatting a time
     * instance.
     */
    public static final int FORMAT_WEEKS = 1 << 7;

    /**
     * Flag indicating whether months must be included when formatting a time
     * instance.
     */
    public static final int FORMAT_MONTHS = 1 << 8;

    /**
     * Flag indicating whether years must be included when formatting a time
     * instance.
     */
    public static final int FORMAT_YEARS = 1 << 9;

    /**
     * Flag indicating whether centuries must be included when formatting a time
     * instance.
     */
    public static final int FORMAT_CENTURIES = 1 << 10;

    /**
     * Flag indicating that all units must be included when formatting a time
     * instance.
     */
    public static final int FORMAT_ALL = FORMAT_NANOSECONDS | FORMAT_MICROSECONDS | FORMAT_MILLISECONDS
            | FORMAT_SECONDS | FORMAT_MINUTES | FORMAT_HOURS | FORMAT_DAYS | FORMAT_WEEKS | FORMAT_MONTHS
            | FORMAT_YEARS | FORMAT_CENTURIES;

    /**
     * Flag indicating that all time units (smaller than a day) must be used when
     * formatting a time instance.
     */
    public static final int FORMAT_TIME_ALL = FORMAT_NANOSECONDS | FORMAT_MICROSECONDS | FORMAT_MILLISECONDS
            | FORMAT_SECONDS | FORMAT_MINUTES | FORMAT_HOURS;

    /**
     * Flag indicating that standard time units (hours, minutes and seconds) must be
     * used when formatting a time instance.
     */
    public static final int FORMAT_TIME_STANDARD = FORMAT_SECONDS | FORMAT_MINUTES | FORMAT_HOURS;

    /**
     * Flag indicating that all date units (greater than hours) must be used when
     * formatting a time instance.
     */
    public static final int FORMAT_DATE_ALL = FORMAT_DAYS | FORMAT_WEEKS | FORMAT_MONTHS | FORMAT_YEARS
            | FORMAT_CENTURIES;

    /**
     * Flag indicating that standard date units (days, months and years) must be
     * used when formatting a time instance.
     */
    public static final int FORMAT_DATE_STANDARD = FORMAT_DAYS | FORMAT_MONTHS | FORMAT_YEARS;

    /**
     * Nanosecond symbol.
     */
    public static final String NANOSECOND_SYMBOL = "ns";

    /**
     * Microsecond symbol.
     */
    public static final String MICROSECOND_SYMBOL = "µs";

    /**
     * Millisecond symbol.
     */
    public static final String MILLISECOND_SYMBOL = "ms";

    /**
     * Second symbol.
     */
    public static final String SECOND_SYMBOL = "s";

    /**
     * Minute symbol.
     */
    public static final String MINUTE_SYMBOL = "min";

    /**
     * Hour symbol.
     */
    public static final String HOUR_SYMBOL = "h";

    /**
     * Day symbol.
     */
    public static final String DAY_SYMBOL = "d";

    /**
     * Week symbol.
     */
    public static final String WEEK_SYMBOL = "wk";

    /**
     * Month symbol.
     */
    public static final String MONTH_SYMBOL = "mon";

    /**
     * Year symbol.
     */
    public static final String YEAR_SYMBOL = "yr";

    /**
     * n-th century symbol.
     */
    public static final String CENTURY_SYMBOL = "th c.";

    /**
     * First century symbol.
     */
    private static final String FIRST_CENTURY_SYMBOL = "st c.";

    /**
     * Second century symbol.
     */
    private static final String SECOND_CENTURY_SYMBOL = "nd c.";

    /**
     * Third century symbol.
     */
    private static final String THIRD_CENTURY_SIMBOL = "rd c.";

    /**
     * Default pattern to format centuries.
     * {0} corresponds to the value, {1} corresponds to the unit part.
     */
    private static final String CENTURY_FORMAT_PATTERN = "{0}{1}";

    /**
     * Minimum number of digits for hours and minutes.
     */
    private static final int INTEGER_DIGITS = 2;

    /**
     * Pattern to format hour and minutes (hh:mm.s).
     */
    private static final String HOUR_MINUTE_PATTERN = "^(\\d+):(\\d{2,})$";


    /**
     * Pattern to format hour, minutes and seconds (hh:mm:ss.ms).
     */
    private static final String HOUR_MINUTE_SECOND_PATTERN = "^(\\d+):(\\d{2}):(\\d{2,})$";

    /**
     * Pattern to parse 1st century format.
     */
    private static final String FIRST_CENTURY_PATTERN = "(.*)(\\s+)(\\d+)(\\s?st c.)(\\s+)(.*)";

    /**
     * Pattern to parse 2nd century format.
     */
    private static final String SECOND_CENTURY_PATTERN = "(.*)(\\s+)(\\d+)(\\s?nd c.)(\\s+)(.*)";

    /**
     * Pattern to parse 3rd century format.
     */
    private static final String THIRD_CENTURY_PATTERN = "(.*)(\\s+)(\\d+)(\\s?rd c.)(\\s+)(.*)";

    /**
     * Pattern to parse n-th century format.
     */
    private static final String CENTURY_PATTERN = "(.*)(\\s+)(\\d+)(\\s?th c.)(\\s+)(.*)";

    /**
     * Pattern to parse year format.
     */
    private static final String YEAR_PATTERN = "(.*)(\\s+)(\\d+)(\\s?yr)(\\s+)(.*)";

    /**
     * Pattern to parse month format.
     */
    private static final String MONTH_PATTERN = "(.*)(\\s+)(\\d+)(\\s?mon)(\\s+)(.*)";

    /**
     * Pattern to parse week format.
     */
    private static final String WEEK_PATTERN = "(.*)(\\s+)(\\d+)(\\s?wk)(\\s+)(.*)";

    /**
     * Pattern to parse day format.
     */
    private static final String DAY_PATTERN = "(.*)(\\s+)(\\d+)(\\s?d)(\\s+)(.*)";

    /**
     * Pattern to parse hour format.
     */
    private static final String HOUR_PATTERN = "(.*)(\\s+)(\\d+)(\\s?h)(\\s+)(.*)";

    /**
     * Pattern to parse minute format.
     */
    private static final String MINUTE_PATTERN = "(.*)(\\s+)(\\d+)(\\s?min)(\\s+)(.*)";

    /**
     * Pattern to parse second format.
     */
    private static final String SECOND_PATTERN = "(.*)(\\s+)(\\d+)(\\s?s)(\\s+)(.*)";

    /**
     * Pattern to parse millisecond format.
     */
    private static final String MILLISECOND_PATTERN = "(.*)(\\s+)(\\d+)(\\s?ms)(\\s+)(.*)";

    /**
     * Pattern to parse microsecond format.
     */
    private static final String MICROSECOND_PATTERN = "(.*)(\\s+)(\\d+)(\\s?µs)(\\s+)(.*)";

    /**
     * Pattern to parse nanosecond format.
     */
    private static final String NANOSECOND_PATTERN = "(.*)(\\s+)(\\d+)(\\s?ns)(\\s+)(.*)";

    /**
     * Defines a space.
     */
    private static final String SPACE = " ";

    /**
     * Pattern to parse time in hour and minute format (hh:mm.s)
     */
    private Pattern hourMinutePattern;

    /**
     * Pattern to parse time in hour, minute and second format (hh:mm:ss.ms).
     */
    private Pattern hourMinuteSecondPattern;

    /**
     * Pattern to parse 1st century format.
     */
    private Pattern firstCenturyPattern;

    /**
     * Pattern to parse 2nd century format.
     */
    private Pattern secondCenturyPattern;

    /**
     * Pattern to parse 3rd century format.
     */
    private Pattern thirdCenturyPattern;

    /**
     * Pattern to parse n-th century format.
     */
    private Pattern centuryPattern;

    /**
     * Pattern to parse year format.
     */
    private Pattern yearPattern;

    /**
     * Pattern to parse month format.
     */
    private Pattern monthPattern;

    /**
     * Pattern to parse week format.
     */
    private Pattern weekPattern;

    /**
     * Pattern to parse day format.
     */
    private Pattern dayPattern;

    /**
     * Pattern to parse hour format.
     */
    private Pattern hourPattern;

    /**
     * Pattern to parse minute format.
     */
    private Pattern minutePattern;

    /**
     * Pattern to parse second format.
     */
    private Pattern secondPattern;

    /**
     * Pattern to parse millisecond format.
     */
    private Pattern millisecondPattern;

    /**
     * Pattern to parse microsecond format.
     */
    private Pattern microsecondPattern;

    /**
     * Pattern to parse nanosecond format.
     */
    private Pattern nanosecondPattern;

    /**
     * Constructor.
     */
    @SuppressWarnings("WeakerAccess")
    public TimeFormatter() {
        super();
    }

    /**
     * Constructor with locale.
     *
     * @param locale locale.
     * @throws IllegalArgumentException if locale is null.
     */
    @SuppressWarnings("WeakerAccess")
    public TimeFormatter(final Locale locale) {
        super(locale);
    }

    /**
     * Copy constructor.
     *
     * @param formatter input instance to copy from.
     * @throws NullPointerException if provided formatter is null.
     */
    public TimeFormatter(final TimeFormatter formatter) {
        this(formatter.getLocale());
        hourMinutePattern = formatter.hourMinutePattern;
        hourMinuteSecondPattern = formatter.hourMinuteSecondPattern;
        firstCenturyPattern = formatter.firstCenturyPattern;
        secondCenturyPattern = formatter.secondCenturyPattern;
        thirdCenturyPattern = formatter.thirdCenturyPattern;
        centuryPattern = formatter.centuryPattern;
        yearPattern = formatter.yearPattern;
        monthPattern = formatter.monthPattern;
        weekPattern = formatter.weekPattern;
        dayPattern = formatter.dayPattern;
        hourPattern = formatter.hourPattern;
        minutePattern = formatter.minutePattern;
        secondPattern = formatter.secondPattern;
        millisecondPattern = formatter.millisecondPattern;
        microsecondPattern = formatter.microsecondPattern;
        nanosecondPattern = formatter.nanosecondPattern;
    }

    /**
     * Determines if two time formatters are equal by comparing all of its internal parameters.
     *
     * @param obj another object to compare.
     * @return true if provided object is assumed to be equal to this instance.
     */
    @Override
    public boolean equals(final Object obj) {
        final var equals = super.equals(obj);
        return (obj instanceof TimeFormatter) && equals;
    }

    /**
     * Hash code generated for this instance.
     * Hash codes can be internally used by some collections to coarsely compare objects.
     * This implementation only calls parent implementation to avoid static analyzer warning.
     *
     * @return hash code.
     */
    @Override
    public int hashCode() {
        return super.hashCode();
    }

    /**
     * Gets unit system for detected unit into provided string representation
     * of a measurement.
     *
     * @param source a measurement string representation to be checked.
     * @return returns metric system only for units belonging to the International
     * System of units.
     */
    @Override
    public UnitSystem getUnitSystem(final String source) {
        final var unit = findUnit(source);
        try {
            return unit != null ? TimeUnit.getUnitSystem(unit) : null;
        } catch (final IllegalArgumentException e) {
            return null;
        }
    }

    /**
     * Formats provided time value and unit into a string representation.
     *
     * @param value a time value.
     * @param unit  a time unit.
     * @return string representation of provided measurement value and unit.
     */
    @Override
    public String format(final Number value, final TimeUnit unit) {
        if (unit == TimeUnit.CENTURY) {
            final var v = value.doubleValue();

            final String symbol;
            if (Math.abs(v) <= 1.0) {
                symbol = FIRST_CENTURY_SYMBOL;
            } else if (Math.abs(v) <= 2.0) {
                symbol = SECOND_CENTURY_SYMBOL;
            } else if (Math.abs(v) <= 3.0) {
                symbol = THIRD_CENTURY_SIMBOL;
            } else {
                symbol = CENTURY_SYMBOL;
            }
            return MessageFormat.format(CENTURY_FORMAT_PATTERN, numberFormat.format(value), symbol);
        } else {
            return super.format(value, unit);
        }
    }

    /**
     * Formats provided time value and unit into a string representation
     * and appends the result into provided string buffer.
     *
     * @param value      a time value.
     * @param unit       a time unit.
     * @param toAppendTo buffer to append the result to.
     * @param pos        field position where result will be appended.
     * @return provided string buffer where result is appended.
     */
    @Override
    public StringBuffer format(
            final Number value, final TimeUnit unit, final StringBuffer toAppendTo, final FieldPosition pos) {
        if (unit == TimeUnit.CENTURY) {
            final var v = value.doubleValue();

            final String symbol;
            if (Math.abs(v) <= 1.0) {
                symbol = FIRST_CENTURY_SYMBOL;
            } else if (Math.abs(v) <= 2.0) {
                symbol = SECOND_CENTURY_SYMBOL;
            } else if (Math.abs(v) <= 3.0) {
                symbol = THIRD_CENTURY_SIMBOL;
            } else {
                symbol = CENTURY_SYMBOL;
            }

            final var format = new MessageFormat(CENTURY_FORMAT_PATTERN);
            return format.format(new Object[]{numberFormat.format(value), symbol}, toAppendTo, pos);
        } else {
            return super.format(value, unit, toAppendTo, pos);
        }
    }

    /**
     * Formats and converts provided time value and unit.
     * Unit system is ignored since time is always expressed in metric system,
     * but some units might not belong to the international system of units.
     * If provided value is too large for provided unit, this method will
     * convert it to a more appropriate unit.
     *
     * @param value  a measurement value.
     * @param unit   a measurement unit.
     * @param system it is ignored.
     * @return a string representation of measurement value and unit.
     */
    @Override
    public String formatAndConvert(
            final Number value, final TimeUnit unit, final UnitSystem system) {
        final var v = value.doubleValue();

        final var nanoseconds = TimeConverter.convert(v, unit, TimeUnit.NANOSECOND);
        if (Math.abs(nanoseconds) < (TimeConverter.SECONDS_PER_MICROSECOND / TimeConverter.SECONDS_PER_NANOSECOND)) {
            return format(nanoseconds, TimeUnit.NANOSECOND);
        }

        final var microseconds = TimeConverter.convert(v, unit, TimeUnit.MICROSECOND);
        if (Math.abs(microseconds) < (TimeConverter.SECONDS_PER_MILLISECOND / TimeConverter.SECONDS_PER_MICROSECOND)) {
            return format(microseconds, TimeUnit.MICROSECOND);
        }

        final var milliseconds = TimeConverter.convert(v, unit, TimeUnit.MILLISECOND);
        if (Math.abs(milliseconds) < (1.0 / TimeConverter.SECONDS_PER_MILLISECOND)) {
            return format(milliseconds, TimeUnit.MILLISECOND);
        }

        final var seconds = TimeConverter.convert(v, unit, TimeUnit.SECOND);
        if (Math.abs(seconds) < TimeConverter.SECONDS_PER_MINUTE) {
            return format(seconds, TimeUnit.SECOND);
        }

        final var minutes = TimeConverter.convert(v, unit, TimeUnit.MINUTE);
        if (Math.abs(minutes) < (TimeConverter.SECONDS_PER_HOUR / TimeConverter.SECONDS_PER_MINUTE)) {
            return format(minutes, TimeUnit.MINUTE);
        }

        final var hours = TimeConverter.convert(v, unit, TimeUnit.HOUR);
        if (Math.abs(hours) < (TimeConverter.SECONDS_PER_DAY / TimeConverter.SECONDS_PER_HOUR)) {
            return format(hours, TimeUnit.HOUR);
        }

        final var days = TimeConverter.convert(v, unit, TimeUnit.DAY);
        if (Math.abs(days) < (TimeConverter.SECONDS_PER_WEEK / TimeConverter.SECONDS_PER_DAY)) {
            return format(days, TimeUnit.DAY);
        }

        final var weeks = TimeConverter.convert(v, unit, TimeUnit.WEEK);
        if (Math.abs(weeks) < (TimeConverter.SECONDS_PER_MONTH / TimeConverter.SECONDS_PER_WEEK)) {
            return format(weeks, TimeUnit.WEEK);
        }

        final var months = TimeConverter.convert(v, unit, TimeUnit.MONTH);
        if (Math.abs(months) < (TimeConverter.SECONDS_PER_YEAR / TimeConverter.SECONDS_PER_MONTH)) {
            return format(months, TimeUnit.MONTH);
        }

        final var years = TimeConverter.convert(v, unit, TimeUnit.YEAR);
        if (Math.abs(years) < (TimeConverter.SECONDS_PER_CENTURY / TimeConverter.SECONDS_PER_YEAR)) {
            return format(years, TimeUnit.YEAR);
        }

        final var centuries = TimeConverter.convert(v, unit, TimeUnit.CENTURY);
        return format(centuries, TimeUnit.CENTURY);
    }

    /**
     * Returns unit system this instance will use based on its assigned locale.
     *
     * @return always returns metric system
     */
    @Override
    public UnitSystem getUnitSystem() {
        return UnitSystem.METRIC;
    }


    /**
     * Parses provided string and tries to determine a time value and unit.
     *
     * @param source text to be parsed.
     * @return time containing a value and unit.
     * @throws ParseException       if provided string cannot be parsed.
     * @throws UnknownUnitException if unit cannot be determined.
     */
    @Override
    public Time parse(final String source) throws ParseException, UnknownUnitException {
        return internalParse(source, new Time());
    }

    /**
     * Attempts to determine a time unit within a measurement string
     * representation.
     *
     * @param source a measurement string representation.
     * @return a time unit, or null if none can be determined.
     */
    @Override
    public TimeUnit findUnit(final String source) {
        if (source.contains(NANOSECOND_SYMBOL + " ") || source.endsWith(NANOSECOND_SYMBOL)) {
            return TimeUnit.NANOSECOND;
        }
        if (source.contains(MICROSECOND_SYMBOL + " ") || source.endsWith(MICROSECOND_SYMBOL)) {
            return TimeUnit.MICROSECOND;
        }
        if (source.contains(MILLISECOND_SYMBOL + " ") || source.endsWith(MILLISECOND_SYMBOL)) {
            return TimeUnit.MILLISECOND;
        }
        if (source.contains(SECOND_SYMBOL + " ") || source.endsWith(SECOND_SYMBOL)) {
            return TimeUnit.SECOND;
        }
        if (source.contains(MINUTE_SYMBOL + " ") || source.endsWith(MINUTE_SYMBOL)) {
            return TimeUnit.MINUTE;
        }
        if (source.contains(WEEK_SYMBOL + " ") || source.endsWith(WEEK_SYMBOL)) {
            return TimeUnit.WEEK;
        }
        if (source.contains(MONTH_SYMBOL + " ") || source.endsWith(MONTH_SYMBOL)) {
            return TimeUnit.MONTH;
        }
        if (source.contains(YEAR_SYMBOL + " ") || source.endsWith(YEAR_SYMBOL)) {
            return TimeUnit.YEAR;
        }
        if (source.contains(CENTURY_SYMBOL) || source.contains(FIRST_CENTURY_SYMBOL)
                || source.contains(SECOND_CENTURY_SYMBOL) || source.contains(THIRD_CENTURY_SIMBOL)) {
            return TimeUnit.CENTURY;
        }

        if (source.contains(DAY_SYMBOL + " ") || source.endsWith(DAY_SYMBOL)) {
            return TimeUnit.DAY;
        }
        if (source.contains(HOUR_SYMBOL + " ") || source.endsWith(HOUR_SYMBOL)) {
            return TimeUnit.HOUR;
        }

        return null;
    }

    /**
     * Returns unit string representation.
     *
     * @param unit a measure unit.
     * @return its string representation.
     */
    @Override
    public String getUnitSymbol(final TimeUnit unit) {
        return switch (unit) {
            case NANOSECOND -> NANOSECOND_SYMBOL;
            case MICROSECOND -> MICROSECOND_SYMBOL;
            case MILLISECOND -> MILLISECOND_SYMBOL;
            case MINUTE -> MINUTE_SYMBOL;
            case HOUR -> HOUR_SYMBOL;
            case DAY -> DAY_SYMBOL;
            case WEEK -> WEEK_SYMBOL;
            case MONTH -> MONTH_SYMBOL;
            case YEAR -> YEAR_SYMBOL;
            case CENTURY -> CENTURY_SYMBOL;
            default -> SECOND_SYMBOL;
        };
    }

    /**
     * Formats time instance using hour and minute format (hh:mm.ms).
     *
     * @param time time to be formatted.
     * @return a string representation of provided time instance using hour and
     * minute format (hh:mm.ms).
     */
    public String formatHourMinute(final Time time) {
        final var exactHours = TimeConverter.convert(time.getValue().doubleValue(), time.getUnit(), TimeUnit.HOUR);
        final var hours = Math.floor(exactHours);
        final var diffHours = exactHours - hours;

        final var minutes = TimeConverter.convert(diffHours, TimeUnit.HOUR, TimeUnit.MINUTE);

        final var hourFormat = NumberFormat.getInstance(getLocale());
        hourFormat.setMinimumIntegerDigits(INTEGER_DIGITS);
        hourFormat.setMinimumFractionDigits(0);
        hourFormat.setMaximumFractionDigits(0);

        final var minuteFormat = NumberFormat.getInstance(getLocale());
        minuteFormat.setMinimumIntegerDigits(INTEGER_DIGITS);
        minuteFormat.setMaximumIntegerDigits(INTEGER_DIGITS);

        if (numberFormat != null) {
            hourFormat.setMaximumIntegerDigits(numberFormat.getMaximumIntegerDigits());
            hourFormat.setRoundingMode(numberFormat.getRoundingMode());
            hourFormat.setGroupingUsed(numberFormat.isGroupingUsed());

            minuteFormat.setMinimumFractionDigits(numberFormat.getMinimumFractionDigits());
            minuteFormat.setMaximumFractionDigits(numberFormat.getMaximumFractionDigits());
            minuteFormat.setRoundingMode(numberFormat.getRoundingMode());
            minuteFormat.setGroupingUsed(numberFormat.isGroupingUsed());
        }
        return hourFormat.format(hours) + ":" + minuteFormat.format(minutes);
    }

    /**
     * Parses provided string representation using hour and minute format (hh:mm).
     * Note: decimals are not accepted on either hour or minute values.
     *
     * @param source string to be parsed.
     * @return parsed time.
     * @throws ParseException       if parsing fails.
     * @throws UnknownUnitException if format is not recognized.
     */
    public Time parseHourMinute(final CharSequence source)
            throws ParseException, UnknownUnitException {
        if (hourMinutePattern == null) {
            hourMinutePattern = Pattern.compile(HOUR_MINUTE_PATTERN);
        }

        final var matcher = hourMinutePattern.matcher(source);
        if (!matcher.matches()) {
            throw new UnknownUnitException();
        }

        final var hourString = matcher.group(1);
        final var minuteString = matcher.group(2);

        final var hour = numberFormat.parse(hourString);
        final var minute = numberFormat.parse(minuteString);

        final var result = new Time(hour, TimeUnit.HOUR);
        TimeConverter.convert(result, TimeUnit.MINUTE);
        result.add(new Time(minute, TimeUnit.MINUTE));
        return result;
    }

    /**
     * Formats this instance using hour, minute and second format (hh:mm::ss.ms).
     *
     * @param time time to be formatted.
     * @return a string representation of provided time instance using hour, minute
     * and second format (hh:mm:ss.ms).
     */
    public String formatHourMinuteSecond(final Time time) {
        final var exactHours = TimeConverter.convert(time.getValue().doubleValue(), time.getUnit(), TimeUnit.HOUR);
        final var hours = Math.floor(exactHours);
        final var diffHours = exactHours - hours;

        final var exactMinutes = TimeConverter.convert(diffHours, TimeUnit.HOUR, TimeUnit.MINUTE);
        final var minutes = Math.floor(exactMinutes);
        final var diffMinutes = exactMinutes - minutes;

        final var seconds = TimeConverter.convert(diffMinutes, TimeUnit.MINUTE, TimeUnit.SECOND);

        final var hourFormat = NumberFormat.getInstance(getLocale());
        hourFormat.setMinimumIntegerDigits(INTEGER_DIGITS);
        hourFormat.setMinimumFractionDigits(0);
        hourFormat.setMaximumFractionDigits(0);

        final var minuteFormat = NumberFormat.getInstance(getLocale());
        minuteFormat.setMinimumIntegerDigits(INTEGER_DIGITS);
        minuteFormat.setMinimumFractionDigits(0);
        minuteFormat.setMaximumFractionDigits(0);

        final var secondFormat = NumberFormat.getInstance(getLocale());
        secondFormat.setMinimumIntegerDigits(INTEGER_DIGITS);
        secondFormat.setMaximumIntegerDigits(INTEGER_DIGITS);

        if (numberFormat != null) {
            hourFormat.setMaximumIntegerDigits(numberFormat.getMaximumIntegerDigits());
            hourFormat.setRoundingMode(numberFormat.getRoundingMode());
            hourFormat.setGroupingUsed(numberFormat.isGroupingUsed());

            minuteFormat.setMaximumIntegerDigits(numberFormat.getMaximumIntegerDigits());
            minuteFormat.setRoundingMode(numberFormat.getRoundingMode());
            minuteFormat.setGroupingUsed(numberFormat.isGroupingUsed());

            secondFormat.setMinimumFractionDigits(numberFormat.getMinimumFractionDigits());
            secondFormat.setMaximumFractionDigits(numberFormat.getMaximumFractionDigits());
            secondFormat.setRoundingMode(numberFormat.getRoundingMode());
            secondFormat.setGroupingUsed(numberFormat.isGroupingUsed());
        }
        return hourFormat.format(hours) + ":" + minuteFormat.format(minutes) + ":" + secondFormat.format(seconds);
    }

    /**
     * Parses provided string representation using hour, minute and second format (hh:mm:ss).
     * Note: decimals are not accepted on either hour, minute or seconds values
     *
     * @param source string to be parsed.
     * @return parsed time.
     * @throws ParseException       if parsing fails.
     * @throws UnknownUnitException if format is not recognized.
     */
    public Time parseHourMinuteSecond(final CharSequence source) throws ParseException, UnknownUnitException {
        if (hourMinuteSecondPattern == null) {
            hourMinuteSecondPattern = Pattern.compile(HOUR_MINUTE_SECOND_PATTERN);
        }

        final var matcher = hourMinuteSecondPattern.matcher(source);
        if (!matcher.matches()) {
            throw new UnknownUnitException();
        }

        final var hourString = matcher.group(1);
        final var minuteString = matcher.group(2);
        final var secondString = matcher.group(3);

        final var hour = numberFormat.parse(hourString);
        final var minute = numberFormat.parse(minuteString);
        final var second = numberFormat.parse(secondString);

        final var result = new Time(hour, TimeUnit.HOUR);
        TimeConverter.convert(result, TimeUnit.MINUTE);
        result.add(new Time(minute, TimeUnit.MINUTE));
        TimeConverter.convert(result, TimeUnit.SECOND);
        result.add(new Time(second, TimeUnit.SECOND));
        return result;
    }

    /**
     * Formats provided time instance using required units as flags.
     * Flags can be provided as bitwise combinations of FORMAT constants.
     * Only non zero units will be included.
     *
     * @param time  time to be formatted.
     * @param flags flags indicating units to include.
     * @return formatted time.
     */
    public String formatMultiple(final Time time, final int flags) {
        return formatMultiple(time, flags, true);
    }

    /**
     * Formats provided time instance using required units as flags.
     * Flags can be provided as bitwise combinations of FORMAT constants.
     *
     * @param time        time to be formatted.
     * @param flags       flags indicating units to include.
     * @param onlyNonZero true indicates to include only non zero units, false to
     *                    include all selected units even if they are zero.
     * @return formatted time.
     */
    public String formatMultiple(final Time time, final int flags, final boolean onlyNonZero) {

        // centuries
        final var exactCenturies = TimeConverter.convert(time.getValue().doubleValue(), time.getUnit(),
                TimeUnit.CENTURY);
        var centuries = 0.0;
        double diffCenturies;
        if ((flags & FORMAT_CENTURIES) != 0) {
            if ((flags & (FORMAT_YEARS | FORMAT_MONTHS | FORMAT_WEEKS | FORMAT_DAYS | FORMAT_HOURS | FORMAT_MINUTES
                    | FORMAT_SECONDS | FORMAT_MILLISECONDS | FORMAT_MICROSECONDS | FORMAT_NANOSECONDS)) != 0) {
                // there are smaller units
                centuries = Math.floor(exactCenturies);
            } else {
                centuries = exactCenturies;
            }
            diffCenturies = exactCenturies - centuries;
        } else {
            diffCenturies = exactCenturies;
        }

        // years
        final var exactYears = TimeConverter.convert(diffCenturies, TimeUnit.CENTURY, TimeUnit.YEAR);
        var years = 0.0;
        double diffYears;
        if ((flags & FORMAT_YEARS) != 0) {
            if ((flags & (FORMAT_MONTHS | FORMAT_WEEKS | FORMAT_DAYS | FORMAT_HOURS | FORMAT_MINUTES | FORMAT_SECONDS
                    | FORMAT_MILLISECONDS | FORMAT_MICROSECONDS | FORMAT_NANOSECONDS)) != 0) {
                // there are smaller units
                years = Math.floor(exactYears);
            } else {
                years = exactYears;
            }
            diffYears = exactYears - years;
        } else {
            diffYears = exactYears;
        }

        // months
        final var exactMonths = TimeConverter.convert(diffYears, TimeUnit.YEAR, TimeUnit.MONTH);
        var months = 0.0;
        double diffMonths;
        if ((flags & FORMAT_MONTHS) != 0) {
            if ((flags & (FORMAT_WEEKS | FORMAT_DAYS | FORMAT_HOURS | FORMAT_MINUTES | FORMAT_SECONDS
                    | FORMAT_MILLISECONDS | FORMAT_MICROSECONDS | FORMAT_NANOSECONDS)) != 0) {
                // there are smaller units
                months = Math.floor(exactMonths);
            } else {
                months = exactMonths;
            }
            diffMonths = exactMonths - months;
        } else {
            diffMonths = exactMonths;
        }

        // weeks
        final var exactWeeks = TimeConverter.convert(diffMonths, TimeUnit.MONTH, TimeUnit.WEEK);
        var weeks = 0.0;
        double diffWeeks;
        if ((flags & FORMAT_WEEKS) != 0) {
            if ((flags & (FORMAT_DAYS | FORMAT_HOURS | FORMAT_MINUTES | FORMAT_SECONDS | FORMAT_MILLISECONDS
                    | FORMAT_MICROSECONDS | FORMAT_NANOSECONDS)) != 0) {
                // there are smaller units
                weeks = Math.floor(exactWeeks);
            } else {
                weeks = exactWeeks;
            }
            diffWeeks = exactWeeks - weeks;
        } else {
            diffWeeks = exactWeeks;
        }

        // days
        final var exactDays = TimeConverter.convert(diffWeeks, TimeUnit.WEEK, TimeUnit.DAY);
        var days = 0.0;
        double diffDays;
        if ((flags & FORMAT_DAYS) != 0) {
            if ((flags & (FORMAT_HOURS | FORMAT_MINUTES | FORMAT_SECONDS | FORMAT_MILLISECONDS | FORMAT_MICROSECONDS
                    | FORMAT_NANOSECONDS)) != 0) {
                // there are smaller units
                days = Math.floor(exactDays);
            } else {
                days = exactDays;
            }
            diffDays = exactDays - days;
        } else {
            diffDays = exactDays;
        }

        // hours
        final var exactHours = TimeConverter.convert(diffDays, TimeUnit.DAY, TimeUnit.HOUR);
        var hours = 0.0;
        double diffHours;
        if ((flags & FORMAT_HOURS) != 0) {
            if ((flags & (FORMAT_MINUTES | FORMAT_SECONDS | FORMAT_MILLISECONDS | FORMAT_MICROSECONDS
                    | FORMAT_NANOSECONDS)) != 0) {
                // there are smaller units
                hours = Math.floor(exactHours);
            } else {
                hours = exactHours;
            }
            diffHours = exactHours - hours;
        } else {
            diffHours = exactHours;
        }

        // minutes
        final var exactMinutes = TimeConverter.convert(diffHours, TimeUnit.HOUR, TimeUnit.MINUTE);
        var minutes = 0.0;
        double diffMinutes;
        if ((flags & FORMAT_MINUTES) != 0) {
            if ((flags & (FORMAT_SECONDS | FORMAT_MILLISECONDS | FORMAT_MICROSECONDS | FORMAT_NANOSECONDS)) != 0) {
                // there are smaller units
                minutes = Math.floor(exactMinutes);
            } else {
                minutes = exactMinutes;
            }
            diffMinutes = exactMinutes - minutes;
        } else {
            diffMinutes = exactMinutes;
        }

        // seconds
        final var exactSeconds = TimeConverter.convert(diffMinutes, TimeUnit.MINUTE, TimeUnit.SECOND);
        var seconds = 0.0;
        double diffSeconds;
        if ((flags & FORMAT_SECONDS) != 0) {
            if ((flags & (FORMAT_MILLISECONDS | FORMAT_MICROSECONDS | FORMAT_NANOSECONDS)) != 0) {
                // there are smaller units
                seconds = Math.floor(exactSeconds);
            } else {
                seconds = exactSeconds;
            }
            diffSeconds = exactSeconds - seconds;
        } else {
            diffSeconds = exactSeconds;
        }

        // milliseconds
        final var exactMilliseconds = TimeConverter.convert(diffSeconds, TimeUnit.SECOND, TimeUnit.MILLISECOND);
        var milliseconds = 0.0;
        double diffMilliseconds;
        if ((flags & FORMAT_MILLISECONDS) != 0) {
            if ((flags & (FORMAT_MICROSECONDS | FORMAT_NANOSECONDS)) != 0) {
                // there are smaller units
                milliseconds = Math.floor(exactMilliseconds);
            } else {
                milliseconds = exactMilliseconds;
            }
            diffMilliseconds = exactMilliseconds - milliseconds;
        } else {
            diffMilliseconds = exactMilliseconds;
        }

        // microseconds
        final var exactMicroseconds = TimeConverter.convert(diffMilliseconds, TimeUnit.MILLISECOND,
                TimeUnit.MICROSECOND);
        var microseconds = 0.0;
        double diffMicroseconds;
        if ((flags & FORMAT_MICROSECONDS) != 0) {
            if ((flags & (FORMAT_NANOSECONDS)) != 0) {
                microseconds = Math.floor(exactMicroseconds);
            } else {
                microseconds = exactMicroseconds;
            }
            diffMicroseconds = exactMicroseconds - microseconds;
        } else {
            diffMicroseconds = exactMicroseconds;
        }

        // nanoseconds
        var nanoseconds = 0.0;
        if ((flags & FORMAT_NANOSECONDS) != 0) {
            nanoseconds = TimeConverter.convert(diffMicroseconds, TimeUnit.MICROSECOND, TimeUnit.NANOSECOND);
        }

        // format result
        final var builder = new StringBuilder();
        if (((flags & FORMAT_CENTURIES) != 0) && (!onlyNonZero || centuries != 0.0)) {
            builder.append(format(centuries, TimeUnit.CENTURY));
        }
        if (((flags & FORMAT_YEARS) != 0) && (!onlyNonZero || years != 0.0)) {
            appendSpaceIfNeeded(builder).append(format(years, TimeUnit.YEAR));
        }
        if (((flags & FORMAT_MONTHS) != 0) && (!onlyNonZero || months != 0.0)) {
            appendSpaceIfNeeded(builder).append(format(months, TimeUnit.MONTH));
        }
        if (((flags & FORMAT_WEEKS) != 0) && (!onlyNonZero || weeks != 0.0)) {
            appendSpaceIfNeeded(builder).append(format(weeks, TimeUnit.WEEK));
        }
        if (((flags & FORMAT_DAYS) != 0) && (!onlyNonZero || days != 0.0)) {
            appendSpaceIfNeeded(builder).append(
                    format(days, TimeUnit.DAY));
        }
        if (((flags & FORMAT_HOURS) != 0) && (!onlyNonZero || hours != 0.0)) {
            appendSpaceIfNeeded(builder).append(format(hours, TimeUnit.HOUR));
        }
        if (((flags & FORMAT_MINUTES) != 0) && (!onlyNonZero || minutes != 0.0)) {
            appendSpaceIfNeeded(builder).append(format(minutes, TimeUnit.MINUTE));
        }
        if (((flags & FORMAT_SECONDS) != 0) && (!onlyNonZero || seconds != 0.0)) {
            appendSpaceIfNeeded(builder).append(format(seconds, TimeUnit.SECOND));
        }
        if (((flags & FORMAT_MILLISECONDS) != 0) && (!onlyNonZero || milliseconds != 0.0)) {
            appendSpaceIfNeeded(builder).append(format(milliseconds, TimeUnit.MILLISECOND));
        }
        if (((flags & FORMAT_MICROSECONDS) != 0) && (!onlyNonZero || microseconds != 0.0)) {
            appendSpaceIfNeeded(builder).append(format(microseconds, TimeUnit.MICROSECOND));
        }
        if (((flags & FORMAT_NANOSECONDS) != 0) && (!onlyNonZero || nanoseconds != 0.0)) {
            appendSpaceIfNeeded(builder).append(format(nanoseconds, TimeUnit.NANOSECOND));
        }
        return builder.toString();
    }

    /**
     * Parses a string containing multiple units and returns the summation of all
     * found values as a single Time instance.
     * This method does not allow decimal values with fractions or thousand
     * separators.
     *
     * @param source string to be parsed.
     * @return summation of all time values that have been parsed.
     * @throws ParseException       if parsing fails.
     * @throws UnknownUnitException if format is not recognized.
     */
    public Time parseMultiple(final CharSequence source) throws ParseException, UnknownUnitException {
        final var wrapped = " " + source + " ";
        Time result = null;

        final var firstCentury = parse1stCentury(wrapped);
        final var secondCentury = parse2ndCentury(wrapped);
        final var thirdCentury = parse3rdCentury(wrapped);
        final var century = parseCentury(wrapped);
        final var year = parseYear(wrapped);
        final var month = parseMonth(wrapped);
        final var week = parseWeek(wrapped);
        final var day = parseDay(wrapped);
        final var hour = parseHour(wrapped);
        final var minute = parseMinute(wrapped);
        final var second = parseSecond(wrapped);
        final var millisecond = parseMillisecond(wrapped);
        final var microsecond = parseMicrosecond(wrapped);
        final var nanosecond = parseNanosecond(wrapped);

        // century
        if (firstCentury != null) {
            result = firstCentury;
        }
        if (secondCentury != null) {
            result = secondCentury;
        }
        if (thirdCentury != null) {
            result = thirdCentury;
        }
        if (century != null) {
            result = century;
        }

        // year
        if (year != null) {
            if (result == null) {
                result = year;
            } else {
                result.add(year);
            }
        }

        // month
        if (month != null) {
            if (result == null) {
                result = month;
            } else {
                result.add(month);
            }
        }

        // week
        if (week != null) {
            if (result == null) {
                result = week;
            } else {
                result.add(week);
            }
        }

        // day
        if (day != null) {
            if (result == null) {
                result = day;
            } else {
                result.add(day);
            }
        }

        // hour
        if (hour != null) {
            if (result == null) {
                result = hour;
            } else {
                result.add(hour);
            }
        }

        // minute
        if (minute != null) {
            if (result == null) {
                result = minute;
            } else {
                result.add(minute);
            }
        }

        // second
        if (second != null) {
            if (result == null) {
                result = second;
            } else {
                result.add(second);
            }
        }

        // millisecond
        if (millisecond != null) {
            if (result == null) {
                result = millisecond;
            } else {
                result.add(millisecond);
            }
        }

        // microsecond
        if (microsecond != null) {
            if (result == null) {
                result = microsecond;
            } else {
                result.add(microsecond);
            }
        }

        // nanosecond
        if (nanosecond != null) {
            if (result == null) {
                result = nanosecond;
            } else {
                result.add(nanosecond);
            }
        }

        if (result == null) {
            // no valid unit was found
            throw new UnknownUnitException();
        }

        return result;
    }

    /**
     * Parses string as 1st century format.
     * This method does not allow decimal values with fractions or thousand
     * separators.
     *
     * @param source string to be parsed.
     * @return parsed time in century units.
     * @throws ParseException if parsing fails.
     */
    private Time parse1stCentury(final CharSequence source) throws ParseException {
        if (firstCenturyPattern == null) {
            firstCenturyPattern = Pattern.compile(FIRST_CENTURY_PATTERN);
        }

        final var matcher = firstCenturyPattern.matcher(source);
        if (!matcher.matches()) {
            return null;
        }

        return new Time(numberFormat.parse(matcher.group(3)), TimeUnit.CENTURY);
    }

    /**
     * Parses string as 2nd century format.
     * This method does not allow decimal values with fractions or thousand
     * separators.
     *
     * @param source string to be parsed.
     * @return parsed time in century units.
     * @throws ParseException if parsing fails.
     */
    private Time parse2ndCentury(final CharSequence source) throws ParseException {
        if (secondCenturyPattern == null) {
            secondCenturyPattern = Pattern.compile(SECOND_CENTURY_PATTERN);
        }

        final var matcher = secondCenturyPattern.matcher(source);
        if (!matcher.matches()) {
            return null;
        }

        return new Time(numberFormat.parse(matcher.group(3)), TimeUnit.CENTURY);
    }

    /**
     * Parses string as 3rd century format.
     * This method does not allow decimal values with fractions or thousand
     * separators.
     *
     * @param source string to be parsed.
     * @return parsed time in century units.
     * @throws ParseException if parsing fails.
     */
    private Time parse3rdCentury(final CharSequence source) throws ParseException {
        if (thirdCenturyPattern == null) {
            thirdCenturyPattern = Pattern.compile(THIRD_CENTURY_PATTERN);
        }

        final var matcher = thirdCenturyPattern.matcher(source);
        if (!matcher.matches()) {
            return null;
        }

        return new Time(numberFormat.parse(matcher.group(3)), TimeUnit.CENTURY);
    }

    /**
     * Parses string as n-th century format.
     * This method does not allow decimal values with fractions or thousand
     * separators.
     *
     * @param source string to be parsed.
     * @return parsed time in century units.
     * @throws ParseException if parsing fails.
     */
    private Time parseCentury(final CharSequence source) throws ParseException {
        if (centuryPattern == null) {
            centuryPattern = Pattern.compile(CENTURY_PATTERN);
        }

        final var matcher = centuryPattern.matcher(source);
        if (!matcher.matches()) {
            return null;
        }

        return new Time(numberFormat.parse(matcher.group(3)), TimeUnit.CENTURY);
    }

    /**
     * Parses string as year format.
     * This method does not allow decimal values with fractions or thousand
     * separators.
     *
     * @param source string to be parsed.
     * @return parsed time in year units.
     * @throws ParseException if parsing fails.
     */
    private Time parseYear(final CharSequence source) throws ParseException {
        if (yearPattern == null) {
            yearPattern = Pattern.compile(YEAR_PATTERN);
        }

        final var matcher = yearPattern.matcher(source);
        if (!matcher.matches()) {
            return null;
        }

        return new Time(numberFormat.parse(matcher.group(3)), TimeUnit.YEAR);
    }

    /**
     * Parses string as month format.
     * This method does not allow decimal values with fractions or thousand
     * separators.
     *
     * @param source string to be parsed.
     * @return parsed time in month units.
     * @throws ParseException if parsing fails.
     */
    private Time parseMonth(final CharSequence source) throws ParseException {
        if (monthPattern == null) {
            monthPattern = Pattern.compile(MONTH_PATTERN);
        }

        final var matcher = monthPattern.matcher(source);
        if (!matcher.matches()) {
            return null;
        }

        return new Time(numberFormat.parse(matcher.group(3)), TimeUnit.MONTH);
    }

    /**
     * Parses string as week format.
     * This method does not allow decimal values with fractions or thousand
     * separators.
     *
     * @param source string to be parsed.
     * @return parsed time in week units.
     * @throws ParseException if parsing fails.
     */
    private Time parseWeek(final CharSequence source) throws ParseException {
        if (weekPattern == null) {
            weekPattern = Pattern.compile(WEEK_PATTERN);
        }

        final var matcher = weekPattern.matcher(source);
        if (!matcher.matches()) {
            return null;
        }

        return new Time(numberFormat.parse(matcher.group(3)), TimeUnit.WEEK);
    }

    /**
     * Parses string as day format.
     * This method does not allow decimal values with fractions or thousand
     * separators.
     *
     * @param source string to be parsed.
     * @return parsed time in day units.
     * @throws ParseException if parsing fails.
     */
    private Time parseDay(final CharSequence source) throws ParseException {
        if (dayPattern == null) {
            dayPattern = Pattern.compile(DAY_PATTERN);
        }

        final var matcher = dayPattern.matcher(source);
        if (!matcher.matches()) {
            return null;
        }

        return new Time(numberFormat.parse(matcher.group(3)), TimeUnit.DAY);
    }

    /**
     * Parses string as hour format.
     * This method does not allow decimal values with fractions or thousand
     * separators.
     *
     * @param source string to be parsed.
     * @return parsed time in hour units.
     * @throws ParseException if parsing fails.
     */
    private Time parseHour(final CharSequence source) throws ParseException {
        if (hourPattern == null) {
            hourPattern = Pattern.compile(HOUR_PATTERN);
        }

        final var matcher = hourPattern.matcher(source);
        if (!matcher.matches()) {
            return null;
        }

        return new Time(numberFormat.parse(matcher.group(3)), TimeUnit.HOUR);
    }

    /**
     * Parses string as minute format.
     * This method does not allow decimal values with fractions or thousand
     * separators.
     *
     * @param source string to be parsed.
     * @return parsed time in minute units.
     * @throws ParseException if parsing fails.
     */
    private Time parseMinute(final CharSequence source) throws ParseException {
        if (minutePattern == null) {
            minutePattern = Pattern.compile(MINUTE_PATTERN);
        }

        final var matcher = minutePattern.matcher(source);
        if (!matcher.matches()) {
            return null;
        }

        return new Time(numberFormat.parse(matcher.group(3)), TimeUnit.MINUTE);
    }

    /**
     * Parses string as second format.
     * This method does not allow decimal values with fractions or thousand
     * separators.
     *
     * @param source string to be parsed.
     * @return parsed time in second units.
     * @throws ParseException if parsing fails.
     */
    private Time parseSecond(final CharSequence source) throws ParseException {
        if (secondPattern == null) {
            secondPattern = Pattern.compile(SECOND_PATTERN);
        }

        final var matcher = secondPattern.matcher(source);
        if (!matcher.matches()) {
            return null;
        }

        return new Time(numberFormat.parse(matcher.group(3)), TimeUnit.SECOND);
    }

    /**
     * Parses string as millisecond format.
     * This method does not allow decimal values with fractions or thousand
     * separators.
     *
     * @param source string to be parsed.
     * @return parsed time in millisecond units.
     * @throws ParseException if parsing fails.
     */
    private Time parseMillisecond(final CharSequence source) throws ParseException {
        if (millisecondPattern == null) {
            millisecondPattern = Pattern.compile(MILLISECOND_PATTERN);
        }

        final var matcher = millisecondPattern.matcher(source);
        if (!matcher.matches()) {
            return null;
        }

        return new Time(numberFormat.parse(matcher.group(3)), TimeUnit.MILLISECOND);
    }

    /**
     * Parses string as microsecond format.
     * This method does not allow decimal values with fractions or thousand
     * separators.
     *
     * @param source string to be parsed.
     * @return parsed time in microsecond units.
     * @throws ParseException if parsing fails.
     */
    private Time parseMicrosecond(final CharSequence source) throws ParseException {
        if (microsecondPattern == null) {
            microsecondPattern = Pattern.compile(MICROSECOND_PATTERN);
        }

        final var matcher = microsecondPattern.matcher(source);
        if (!matcher.matches()) {
            return null;
        }

        return new Time(numberFormat.parse(matcher.group(3)), TimeUnit.MICROSECOND);
    }

    /**
     * Parses string as nanosecond format.
     * This method does not allow decimal values with fractions or thousand
     * separators.
     *
     * @param source string to be parsed.
     * @return parsed time in nanosecond units.
     * @throws ParseException if parsing fails.
     */
    private Time parseNanosecond(final CharSequence source) throws ParseException {
        if (nanosecondPattern == null) {
            nanosecondPattern = Pattern.compile(NANOSECOND_PATTERN);
        }

        final var matcher = nanosecondPattern.matcher(source);
        if (!matcher.matches()) {
            return null;
        }

        return new Time(numberFormat.parse(matcher.group(3)), TimeUnit.NANOSECOND);
    }

    /**
     * Appends a space if builder is not empty.
     *
     * @param builder builder to add space to.
     * @return same instance provided as parameter.
     */
    private StringBuilder appendSpaceIfNeeded(final StringBuilder builder) {
        if (!builder.isEmpty()) {
            builder.append(SPACE);
        }
        return builder;
    }
}