/*
 * Copyright (C) 2017 The Android Open Source Project
 *
 * 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.android.i18n.test.timezone;

import org.junit.Test;

import android.icu.testsharding.MainTestShard;
import android.icu.util.TimeZone;

import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
import com.android.i18n.timezone.CountryTimeZones;
import com.android.i18n.timezone.CountryTimeZones.OffsetResult;
import com.android.i18n.timezone.CountryTimeZones.TimeZoneMapping;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

@MainTestShard
public class CountryTimeZonesTest {

    private static final int HOUR_MILLIS = 60 * 60 * 1000;

    private static final String INVALID_TZ_ID = "Moon/Tranquility_Base";

    // Zones used in the tests. NY_TZ and LON_TZ chosen because they never overlap but both have
    // DST.
    private static final TimeZone NY_TZ = TimeZone.getTimeZone("America/New_York");
    private static final TimeZone LON_TZ = TimeZone.getTimeZone("Europe/London");
    // A zone that matches LON_TZ for WHEN_NO_DST. It does not have DST so differs for WHEN_DST.
    private static final TimeZone REYK_TZ = TimeZone.getTimeZone("Atlantic/Reykjavik");
    // Another zone that matches LON_TZ for WHEN_NO_DST. It does not have DST so differs for
    // WHEN_DST.
    private static final TimeZone UTC_TZ = TimeZone.getTimeZone("Etc/UTC");

    // 22nd July 2017, 13:14:15 UTC (DST time in all the timezones used in these tests that observe
    // DST).
    private static final long WHEN_DST = 1500729255000L;
    // 22nd January 2018, 13:14:15 UTC (non-DST time in all timezones used in these tests).
    private static final long WHEN_NO_DST = 1516626855000L;

    // The offset applied to most zones during DST.
    private static final int NORMAL_DST_ADJUSTMENT = HOUR_MILLIS;

    private static final int LON_NO_DST_TOTAL_OFFSET = 0;
    private static final int LON_DST_TOTAL_OFFSET = LON_NO_DST_TOTAL_OFFSET
            + NORMAL_DST_ADJUSTMENT;

    private static final int NY_NO_DST_TOTAL_OFFSET = -5 * HOUR_MILLIS;
    private static final int NY_DST_TOTAL_OFFSET = NY_NO_DST_TOTAL_OFFSET
            + NORMAL_DST_ADJUSTMENT;

    @Test
    public void createValidated() throws Exception {
        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
                "gb", "Europe/London", false /* defaultTimeZoneBoost */,
                true /* everUsesUtc */, timeZoneMappings("Europe/London"), "test");
        assertTrue(countryTimeZones.matchesCountryCode("gb"));
        assertEquals("Europe/London", countryTimeZones.getDefaultTimeZoneId());
        assertZoneEquals(zone("Europe/London"), countryTimeZones.getDefaultTimeZone());
        assertEquals(timeZoneMappings("Europe/London"), countryTimeZones.getTimeZoneMappings());
        assertEquals(timeZoneMappings("Europe/London"),
                countryTimeZones.getEffectiveTimeZoneMappingsAt(0 /* whenMillis */));
    }

    @Test
    public void createValidated_nullDefault() throws Exception {
        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
                "gb", null, false /* defaultTimeZoneBoost */, true /* everUsesUtc */,
                timeZoneMappings("Europe/London"), "test");
        assertNull(countryTimeZones.getDefaultTimeZoneId());
        assertNull(countryTimeZones.getDefaultTimeZone());
    }

    @Test
    public void createValidated_invalidDefault() throws Exception {
        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
                "gb", INVALID_TZ_ID, false /* defaultTimeZoneBoost */, true /* everUsesUtc */,
                timeZoneMappings("Europe/London", INVALID_TZ_ID), "test");
        assertNull(countryTimeZones.getDefaultTimeZoneId());
        assertNull(countryTimeZones.getDefaultTimeZone());
        assertEquals(timeZoneMappings("Europe/London"), countryTimeZones.getTimeZoneMappings());
        assertEquals(timeZoneMappings("Europe/London"),
                countryTimeZones.getEffectiveTimeZoneMappingsAt(0 /* whenMillis */));
    }

    @Test
    public void createValidated_unknownTimeZoneIdIgnored() throws Exception {
        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
                "gb", "Europe/London", false /* defaultTimeZoneBoost */, true /* everUsesUtc */,
                timeZoneMappings("Unknown_Id", "Europe/London"), "test");
        assertEquals(timeZoneMappings("Europe/London"), countryTimeZones.getTimeZoneMappings());
        assertEquals(timeZoneMappings("Europe/London"),
                countryTimeZones.getEffectiveTimeZoneMappingsAt(0 /* whenMillis */));
    }

    @Test
    public void matchesCountryCode() throws Exception {
        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
                "gb", "Europe/London", false /* defaultTimeZoneBoost */, true /* everUsesUtc */,
                timeZoneMappings("Europe/London"), "test");
        assertTrue(countryTimeZones.matchesCountryCode("GB"));
        assertTrue(countryTimeZones.matchesCountryCode("Gb"));
        assertTrue(countryTimeZones.matchesCountryCode("gB"));
    }

    @Test
    public void structuresAreImmutable() throws Exception {
        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
                "gb", "Europe/London", false /* defaultTimeZoneBoost */, true /* everUsesUtc */,
                timeZoneMappings("Europe/London"), "test");

        assertImmutableTimeZone(countryTimeZones.getDefaultTimeZone());

        List<TimeZoneMapping> timeZoneMappings = countryTimeZones.getTimeZoneMappings();
        assertEquals(1, timeZoneMappings.size());
        assertImmutableList(timeZoneMappings);

        List<TimeZoneMapping> effectiveTimeZoneMappings =
                countryTimeZones.getEffectiveTimeZoneMappingsAt(0 /* whenMillis */);
        assertEquals(1, effectiveTimeZoneMappings.size());
        assertImmutableList(effectiveTimeZoneMappings);
    }

    @Test
    public void lookupByOffsetWithBias_oneCandidate() throws Exception {
        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
                "gb", "Europe/London", false /* defaultTimeZoneBoost */, true /* everUsesUtc */,
                timeZoneMappings("Europe/London"), "test");

        OffsetResult lonMatch = new OffsetResult(LON_TZ, true /* oneMatch */);

        // Placeholder constants to improve test case readability.
        final Boolean isDst = true;
        final Boolean notDst = false;
        final Boolean unkIsDst = null;
        final TimeZone noBias = null;
        final OffsetResult noMatch = null;

        Object[][] testCases = new Object[][] {
                // totalOffsetMillis, isDst, whenMillis, bias, expectedMatch

                // The parameters match the zone: total offset and time.
                { LON_DST_TOTAL_OFFSET, unkIsDst, WHEN_DST, noBias, lonMatch },
                { LON_NO_DST_TOTAL_OFFSET, unkIsDst, WHEN_NO_DST, noBias, lonMatch },

                // The parameters match the zone: total offset, isDst and time.
                { LON_DST_TOTAL_OFFSET, isDst, WHEN_DST, noBias, lonMatch },
                { LON_NO_DST_TOTAL_OFFSET, notDst, WHEN_NO_DST, noBias, lonMatch },

                // Some lookup failure cases where the total offset, isDst and time do not match the
                // zone.
                { LON_DST_TOTAL_OFFSET, isDst, WHEN_NO_DST, noBias, noMatch },
                { LON_DST_TOTAL_OFFSET, notDst, WHEN_NO_DST, noBias, noMatch },
                { LON_NO_DST_TOTAL_OFFSET, isDst, WHEN_DST, noBias, noMatch },
                { LON_NO_DST_TOTAL_OFFSET, isDst, WHEN_NO_DST, noBias, noMatch },
                { LON_DST_TOTAL_OFFSET, notDst, WHEN_DST, noBias, noMatch },
                { LON_NO_DST_TOTAL_OFFSET, notDst, WHEN_DST, noBias, noMatch },

                // Some bias cases below.

                // The bias is irrelevant here: it matches what would be returned anyway.
                { LON_DST_TOTAL_OFFSET, isDst, WHEN_DST, LON_TZ, lonMatch },
                { LON_NO_DST_TOTAL_OFFSET, notDst, WHEN_NO_DST, LON_TZ, lonMatch },

                // A sample of a non-matching case with bias.
                { LON_DST_TOTAL_OFFSET, isDst, WHEN_NO_DST, LON_TZ, noMatch },

                // The bias should be ignored: it doesn't match any of the country's zones.
                { LON_DST_TOTAL_OFFSET, isDst, WHEN_DST, NY_TZ, lonMatch },

                // The bias should still be ignored even though it matches the offset information
                // given it doesn't match any of the country's zones.
                { NY_DST_TOTAL_OFFSET, isDst, WHEN_DST, NY_TZ, noMatch },
        };
        executeLookupByOffsetWithBiasTestCases(countryTimeZones, testCases);
    }

    @Test
    public void lookupByOffsetWithBias_multipleNonOverlappingCandidates() throws Exception {
        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
                "xx", "Europe/London", false /* defaultTimeZoneBoost */, true /* everUsesUtc */,
                timeZoneMappings("America/New_York", "Europe/London"), "test");

        OffsetResult lonMatch = new OffsetResult(LON_TZ, true /* oneMatch */);
        OffsetResult nyMatch = new OffsetResult(NY_TZ, true /* oneMatch */);

        // Placeholder constants to improve test case readability.
        final Boolean isDst = true;
        final Boolean notDst = false;
        final Boolean unkIsDst = null;
        final TimeZone noBias = null;
        final OffsetResult noMatch = null;

        Object[][] testCases = new Object[][] {
                // totalOffsetMillis, isDst, dstOffsetMillis, whenMillis, bias, expectedMatch

                // The parameters match the zone: total offset and time.
                { LON_DST_TOTAL_OFFSET, unkIsDst, WHEN_DST, noBias, lonMatch },
                { LON_NO_DST_TOTAL_OFFSET, unkIsDst, WHEN_NO_DST, noBias, lonMatch },
                { NY_NO_DST_TOTAL_OFFSET, unkIsDst, WHEN_NO_DST, noBias, nyMatch },
                { NY_DST_TOTAL_OFFSET, unkIsDst, WHEN_DST, noBias, nyMatch },

                // The parameters match the zone: total offset, isDst and time.
                { LON_DST_TOTAL_OFFSET, isDst, WHEN_DST, noBias, lonMatch },
                { LON_NO_DST_TOTAL_OFFSET, notDst, WHEN_NO_DST, noBias, lonMatch },
                { NY_DST_TOTAL_OFFSET, isDst, WHEN_DST, noBias, nyMatch },
                { NY_NO_DST_TOTAL_OFFSET, notDst, WHEN_NO_DST, noBias, nyMatch },

                // Some lookup failure cases where the total offset, isDst and time do not match the
                // zone. This is a sample, not complete.
                { LON_DST_TOTAL_OFFSET, isDst, WHEN_NO_DST, noBias, noMatch },
                { LON_DST_TOTAL_OFFSET, unkIsDst, WHEN_NO_DST, noBias, noMatch },
                { LON_DST_TOTAL_OFFSET, notDst, WHEN_DST, noBias, noMatch },
                { LON_NO_DST_TOTAL_OFFSET, isDst, WHEN_DST, noBias, noMatch },
                { LON_NO_DST_TOTAL_OFFSET, isDst, WHEN_NO_DST, noBias, noMatch },
                { LON_NO_DST_TOTAL_OFFSET, unkIsDst, WHEN_DST, noBias, noMatch },
                { LON_NO_DST_TOTAL_OFFSET, notDst, WHEN_DST, noBias, noMatch },

                // Some bias cases below.

                // The bias is irrelevant here: it matches what would be returned anyway.
                { LON_DST_TOTAL_OFFSET, isDst, WHEN_DST, LON_TZ, lonMatch },
                { LON_DST_TOTAL_OFFSET, unkIsDst, WHEN_DST, LON_TZ, lonMatch },
                { LON_NO_DST_TOTAL_OFFSET, unkIsDst, WHEN_NO_DST, LON_TZ, lonMatch },

                // A sample of non-matching cases with bias.
                { LON_NO_DST_TOTAL_OFFSET, isDst, WHEN_NO_DST, LON_TZ, noMatch },
                { LON_DST_TOTAL_OFFSET, isDst, WHEN_NO_DST, LON_TZ, noMatch },
                { LON_DST_TOTAL_OFFSET, unkIsDst, WHEN_NO_DST, LON_TZ, noMatch },

                // The bias should be ignored: it matches a zone, but the offset is wrong so
                // should not be considered a match.
                { LON_DST_TOTAL_OFFSET, isDst, WHEN_DST, NY_TZ, lonMatch },
                { LON_DST_TOTAL_OFFSET, unkIsDst, WHEN_DST, NY_TZ, lonMatch },
        };
        executeLookupByOffsetWithBiasTestCases(countryTimeZones, testCases);
    }

    // This is an artificial case very similar to America/Denver and America/Phoenix in the US: both
    // have the same offset for 6 months of the year but diverge. Australia/Lord_Howe too.
    @Test
    public void lookupByOffsetWithBias_multipleOverlappingCandidates() throws Exception {
        // Three zones that have the same offset for some of the year. Europe/London changes
        // offset WHEN_DST, the others do not.
        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
                "xx", "Europe/London", false /* defaultTimeZoneBoost */, true /* everUsesUtc */,
                timeZoneMappings("Atlantic/Reykjavik", "Europe/London", "Etc/UTC"), "test");

        // Placeholder constants to improve test case readability.
        final Boolean isDst = true;
        final Boolean notDst = false;
        final Boolean unkIsDst = null;
        final TimeZone noBias = null;
        final OffsetResult noMatch = null;

        // This is the no-DST offset for LON_TZ, REYK_TZ. UTC_TZ.
        final int noDstTotalOffset = LON_NO_DST_TOTAL_OFFSET;
        // This is the DST offset for LON_TZ.
        final int dstTotalOffset = LON_DST_TOTAL_OFFSET;

        OffsetResult lonOnlyMatch = new OffsetResult(LON_TZ, true /* oneMatch */);
        OffsetResult lonBestMatch = new OffsetResult(LON_TZ, false /* oneMatch */);
        OffsetResult reykBestMatch = new OffsetResult(REYK_TZ, false /* oneMatch */);
        OffsetResult utcBestMatch = new OffsetResult(UTC_TZ, false /* oneMatch */);

        Object[][] testCases = new Object[][] {
                // totalOffsetMillis, isDst, dstOffsetMillis, whenMillis, bias, expectedMatch

                // The parameters match one zone: total offset and time.
                { dstTotalOffset, unkIsDst, WHEN_DST, noBias, lonOnlyMatch },
                { dstTotalOffset, unkIsDst, WHEN_DST, noBias, lonOnlyMatch },

                // The parameters match several zones: total offset and time.
                { noDstTotalOffset, unkIsDst, WHEN_NO_DST, noBias, reykBestMatch },
                { noDstTotalOffset, unkIsDst, WHEN_DST, noBias, reykBestMatch },

                // The parameters match one zone: total offset, isDst and time.
                { dstTotalOffset, isDst, WHEN_DST, noBias, lonOnlyMatch },
                { dstTotalOffset, isDst, WHEN_DST, noBias, lonOnlyMatch },

                { noDstTotalOffset, notDst, WHEN_NO_DST, noBias, reykBestMatch },
                { noDstTotalOffset, notDst, WHEN_DST, noBias, reykBestMatch },

                // Some lookup failure cases where the total offset, isDst and time do not match any
                // zone.
                { dstTotalOffset, isDst, WHEN_NO_DST, noBias, noMatch },
                { dstTotalOffset, unkIsDst, WHEN_NO_DST, noBias, noMatch },
                { noDstTotalOffset, isDst, WHEN_NO_DST, noBias, noMatch },
                { noDstTotalOffset, isDst, WHEN_DST, noBias, noMatch },

                // Some bias cases below.

                // Multiple zones match but Reykjavik is the bias.
                { noDstTotalOffset, notDst, WHEN_NO_DST, REYK_TZ, reykBestMatch },

                // Multiple zones match but London is the bias.
                { noDstTotalOffset, notDst, WHEN_NO_DST, LON_TZ, lonBestMatch },

                // Multiple zones match but UTC is the bias.
                { noDstTotalOffset, notDst, WHEN_NO_DST, UTC_TZ, utcBestMatch },

                // The bias should be ignored: it matches a zone, but the offset is wrong so
                // should not be considered a match.
                { LON_DST_TOTAL_OFFSET, isDst, WHEN_DST, REYK_TZ, lonOnlyMatch },
                { LON_DST_TOTAL_OFFSET, unkIsDst, WHEN_DST, REYK_TZ, lonOnlyMatch },
        };
        executeLookupByOffsetWithBiasTestCases(countryTimeZones, testCases);
    }

    private static void executeLookupByOffsetWithBiasTestCases(
            CountryTimeZones countryTimeZones, Object[][] testCases) {

        List<String> failures = new ArrayList<>();
        for (int i = 0; i < testCases.length; i++) {
            Object[] testCase = testCases[i];
            int totalOffsetMillis = (int) testCase[0];
            Boolean isDst = (Boolean) testCase[1];
            long whenMillis = (Long) testCase[2];
            TimeZone bias = (TimeZone) testCase[3];
            OffsetResult expectedMatch = (OffsetResult) testCase[4];

            OffsetResult actualMatch;
            if (isDst == null) {
                actualMatch = countryTimeZones.lookupByOffsetWithBias(
                        whenMillis, bias, totalOffsetMillis);
            } else {
                actualMatch = countryTimeZones.lookupByOffsetWithBias(
                        whenMillis, bias, totalOffsetMillis, isDst);
            }

            if (!offsetResultEquals(expectedMatch, actualMatch)) {
                Function<TimeZone, String> timeZoneFormatter =
                        x -> x == null ? "null" : x.getID();
                Function<OffsetResult, String> offsetResultFormatter =
                        x -> x == null ? "null"
                                : "{" + x.getTimeZone().getID() + ", " + x.isOnlyMatch() + "}";
                failures.add("Fail: case=" + i
                        + ", totalOffsetMillis=" + totalOffsetMillis
                        + ", isDst=" + isDst
                        + ", whenMillis=" + whenMillis
                        + ", bias=" + timeZoneFormatter.apply(bias)
                        + ", expectedMatch=" + offsetResultFormatter.apply(expectedMatch)
                        + ", actualMatch=" + offsetResultFormatter.apply(actualMatch)
                        + "\n");
            }
        }
        if (!failures.isEmpty()) {
            fail("Failed:\n" + failures);
        }
    }

    @Test
    public void getEffectiveTimeZonesAt_noZones() {
        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
                "xx", "Europe/London", false /* defaultTimeZoneBoost */, true /* everUsesUtc */,
                timeZoneMappings(), "test");
        assertEquals(timeZoneMappings(),
                countryTimeZones.getEffectiveTimeZoneMappingsAt(0 /* whenMillis */));
        assertEquals(timeZoneMappings(),
                countryTimeZones.getEffectiveTimeZoneMappingsAt(Long.MIN_VALUE));
        assertEquals(timeZoneMappings(),
                countryTimeZones.getEffectiveTimeZoneMappingsAt(Long.MAX_VALUE));
    }

    @Test
    public void getEffectiveTimeZonesAt_oneZone() {
        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
                "xx", "Europe/London", false /* defaultTimeZoneBoost */, true /* everUsesUtc */,
                timeZoneMappings("Europe/London"), "test");
        assertEquals(timeZoneMappings("Europe/London"),
                countryTimeZones.getEffectiveTimeZoneMappingsAt(0));
        assertEquals(timeZoneMappings("Europe/London"),
                countryTimeZones.getEffectiveTimeZoneMappingsAt(Long.MIN_VALUE));
        assertEquals(timeZoneMappings("Europe/London"),
                countryTimeZones.getEffectiveTimeZoneMappingsAt(Long.MAX_VALUE));
    }

    @Test
    public void getEffectiveTimeZonesAt_filtering() {
        TimeZoneMapping alwaysUsed = timeZoneMapping("Europe/London", null /* notUsedAfter */);

        long mappingNotUsedAfterMillis = 0L;
        TimeZoneMapping notAlwaysUsed = timeZoneMapping("Europe/Paris",
                mappingNotUsedAfterMillis /* notUsedAfter */);

        List<TimeZoneMapping> timeZoneMappings = list(alwaysUsed, notAlwaysUsed);
        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
                "xx", "Europe/London", false /* defaultTimeZoneBoost */, true /* everUsesUtc */,
                timeZoneMappings, "test");

        // Before and at mappingNotUsedAfterMillis, both mappings are "effective".
        assertEquals(list(alwaysUsed, notAlwaysUsed),
                countryTimeZones.getEffectiveTimeZoneMappingsAt(Long.MIN_VALUE));
        assertEquals(list(alwaysUsed, notAlwaysUsed),
                countryTimeZones.getEffectiveTimeZoneMappingsAt(mappingNotUsedAfterMillis));

        // The following should filter the second mapping because it's not "effective" after
        // mappingNotUsedAfterMillis.
        assertEquals(list(alwaysUsed),
                countryTimeZones.getEffectiveTimeZoneMappingsAt(mappingNotUsedAfterMillis + 1));
        assertEquals(list(alwaysUsed),
                countryTimeZones.getEffectiveTimeZoneMappingsAt(Long.MAX_VALUE));
    }

    @Test
    public void hasUtcZone_everUseUtcHintOverridesZoneInformation() {
        // The country has a single zone. Europe/London uses UTC in Winter.
        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
                "xx", "Etc/UTC", false /* defaultTimeZoneBoost */, false /* everUsesUtc */,
                timeZoneMappings("Etc/UTC"), "test");
        assertFalse(countryTimeZones.hasUtcZone(WHEN_DST));
        assertFalse(countryTimeZones.hasUtcZone(WHEN_NO_DST));
    }

    @Test
    public void hasUtcZone_singleZone() {
        // The country has a single zone. Europe/London uses UTC in Winter.
        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
                "xx", "Europe/London", false /* defaultTimeZoneBoost */, true /* everUsesUtc */,
                timeZoneMappings("Europe/London"), "test");
        assertFalse(countryTimeZones.hasUtcZone(WHEN_DST));
        assertTrue(countryTimeZones.hasUtcZone(WHEN_NO_DST));
    }

    @Test
    public void hasUtcZone_multipleZonesWithUtc() {
        // The country has multiple zones. Europe/London uses UTC in Winter.
        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
                "xx", "America/Los_Angeles", false /* defaultTimeZoneBoost */,
                true /* everUsesUtc */,
                timeZoneMappings("America/Los_Angeles", "America/New_York", "Europe/London"),
                "test");
        assertFalse(countryTimeZones.hasUtcZone(WHEN_DST));
        assertTrue(countryTimeZones.hasUtcZone(WHEN_NO_DST));
    }

    @Test
    public void hasUtcZone_multipleZonesWithoutUtc() {
        // The country has multiple zones, none of which use UTC.
        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
                "xx", "Europe/Paris", false /* defaultTimeZoneBoost */, false /* everUsesUtc */,
                timeZoneMappings("America/Los_Angeles", "America/New_York", "Europe/Paris"),
                "test");
        assertFalse(countryTimeZones.hasUtcZone(WHEN_DST));
        assertFalse(countryTimeZones.hasUtcZone(WHEN_NO_DST));
    }

    @Test
    public void hasUtcZone_emptyZones() {
        // The country has no valid zones.
        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
                "xx", INVALID_TZ_ID, false /* defaultTimeZoneBoost */, false /* everUsesUtc */,
                timeZoneMappings(INVALID_TZ_ID), "test");
        assertTrue(countryTimeZones.getTimeZoneMappings().isEmpty());
        assertFalse(countryTimeZones.hasUtcZone(WHEN_DST));
        assertFalse(countryTimeZones.hasUtcZone(WHEN_NO_DST));
    }

    @Test
    public void timeZoneMapping_getTimeZone_badZoneId() {
        TimeZoneMapping timeZoneMapping =
                TimeZoneMapping.createForTests("DOES_NOT_EXIST", true, 1234L, list());
        try {
            timeZoneMapping.getTimeZone();
            fail();
        } catch (RuntimeException expected) {
        }
    }

    @Test
    public void timeZoneMapping_getTimeZone_validZoneId() {
        TimeZoneMapping timeZoneMapping =
                TimeZoneMapping.createForTests("Europe/London", true, 1234L, list());
        TimeZone timeZone = timeZoneMapping.getTimeZone();
        assertTrue(timeZone.isFrozen());
        assertEquals("Europe/London", timeZone.getID());
    }

    @Test
    public void timeZoneMapping_isShownInPickerAfter_notHidden() {
        long notUsedAfter = 1234;
        TimeZoneMapping timeZoneMapping =
                TimeZoneMapping.createForTests(
                        "Europe/London", true /* showInPicker */, notUsedAfter, list());

        assertTrue(timeZoneMapping.isShownInPickerAt(
                Instant.ofEpochMilli(notUsedAfter - 1_000)));
        assertTrue(timeZoneMapping.isShownInPickerAt(Instant.ofEpochMilli(notUsedAfter)));
        assertFalse(timeZoneMapping.isShownInPickerAt(
                Instant.ofEpochMilli(notUsedAfter + 1_000)));
    }

    @Test
    public void timeZoneMapping_isShownInPickerAfter_hiddenTimeZone() {
        long notUsedAfter = 1234;
        TimeZoneMapping hiddenTimeZoneMapping =
                TimeZoneMapping.createForTests(
                        "Moon/Secret_base", false /* showInPicker */, notUsedAfter, list());

        assertFalse(hiddenTimeZoneMapping.isShownInPickerAt(
                Instant.ofEpochMilli(notUsedAfter - 1_000)));
        assertFalse(hiddenTimeZoneMapping.isShownInPickerAt(Instant.ofEpochMilli(notUsedAfter)));
        assertFalse(hiddenTimeZoneMapping.isShownInPickerAt(
                Instant.ofEpochMilli(notUsedAfter + 1_000)));
    }

    private void assertImmutableTimeZone(TimeZone timeZone) {
        try {
            timeZone.setRawOffset(1000);
            fail();
        } catch (UnsupportedOperationException expected) {
        }
    }

    private static <X> void assertImmutableList(List<X> list) {
        try {
            list.add(null);
            fail();
        } catch (UnsupportedOperationException expected) {
        }
    }

    private static void assertZoneEquals(TimeZone expected, TimeZone actual) {
        // TimeZone.equals() only checks the ID, but that's ok for these tests.
        assertEquals(expected, actual);
    }

    private static boolean offsetResultEquals(OffsetResult expected, OffsetResult actual) {
        return expected == actual
                || (expected != null && actual != null
                && Objects.equals(expected.getTimeZone().getID(), actual.getTimeZone().getID())
                && expected.isOnlyMatch() == actual.isOnlyMatch());
    }

    /**
     * Creates a list of default {@link TimeZoneMapping} objects with the specified time zone IDs.
     */
    private static TimeZoneMapping timeZoneMapping(String timeZoneId, Long notUsedAfterMillis) {
        return TimeZoneMapping.createForTests(
                        timeZoneId, true /* picker */, notUsedAfterMillis, list());
    }

    /**
     * Creates a list of default {@link TimeZoneMapping} objects with the specified time zone IDs.
     */
    private static List<TimeZoneMapping> timeZoneMappings(String... timeZoneIds) {
        return Arrays.stream(timeZoneIds)
                .map(x -> timeZoneMapping(x, null /* notUsedAfter */))
                .collect(Collectors.toList());
    }

    private static TimeZone zone(String id) {
        return TimeZone.getFrozenTimeZone(id);
    }

    private static <X> List<X> list(X... xes) {
        return Arrays.asList(xes);
    }
}
