/*
 * Aiven Kafka GCS Connector
 * Copyright (c) 2019 Aiven Oy
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package io.aiven.kafka.connect.gcs.config;

import java.io.IOException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.apache.kafka.common.config.ConfigException;

import io.aiven.kafka.connect.gcs.templating.Template;
import io.aiven.kafka.connect.gcs.templating.VariableTemplatePart;

import com.google.auth.oauth2.UserCredentials;
import com.google.common.collect.ImmutableMap;
import com.google.common.io.Resources;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertIterableEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
 * Tests {@link GcsSinkConfig} class.
 */
final class GcsSinkConfigTest {

    @ParameterizedTest
    @ValueSource(strings = {
        "",
        "{{topic}}", "{{partition}}", "{{start_offset}}",
        "{{topic}}-{{partition}}", "{{topic}}-{{start_offset}}", "{{partition}}-{{start_offset}}",
        "{{topic}}-{{partition}}-{{start_offset}}",
        "{{topic}}-{{partition}}-{{start_offset}}-{{key}}"
    })
    final void incorrectFilenameTemplatesForKey(final String template) {
        final Map<String, String> properties =
            ImmutableMap.of(
                GcsSinkConfig.FILE_NAME_TEMPLATE_CONFIG, template);
        assertThrows(
            ConfigException.class,
            () -> new GcsSinkConfig(properties)
        );
    }

    @ParameterizedTest
    @ValueSource(strings = {
        "",
        "{{topic}}", "{{partition}}", "{{start_offset}}",
        "{{topic}}-{{partition}}", "{{topic}}-{{start_offset}}", "{{partition}}-{{start_offset}}",
        "{{topic}}-{{partition}}-{{start_offset}}-{{unknown}}"
    })
    final void incorrectFilenameTemplatesForTopicPartitionRecord(final String template) {
        final Map<String, String> properties =
            ImmutableMap.of(
                GcsSinkConfig.FILE_NAME_TEMPLATE_CONFIG, template);
        assertThrows(
            ConfigException.class,   
            () -> new GcsSinkConfig(properties)
        );
    }

    @Test
    void acceptMultipleParametersWithTheSameName() {
        final Map<String, String> properties =
            ImmutableMap.of(
                GcsSinkConfig.FILE_NAME_TEMPLATE_CONFIG,
                "{{topic}}-{{timestamp:unit=YYYY}}-"
                    + "{{timestamp:unit=MM}}-{{timestamp:unit=dd}}"
                    + "-{{partition}}-{{start_offset:padding=true}}.gz",
                "gcs.bucket.name", "asdasd"
            );
        final Template t = new GcsSinkConfig(properties).getFilenameTemplate();
        final String fileName = t.instance()
            .bindVariable("topic", () -> "a")
            .bindVariable("timestamp", VariableTemplatePart.Parameter::value)
            .bindVariable("partition", () -> "p")
            .bindVariable("start_offset", VariableTemplatePart.Parameter::value)
            .render();
        assertEquals("a-YYYY-MM-dd-p-true.gz", fileName);
    }

    @Test
    void requiredConfigurations() {
        final Map<String, String> properties = new HashMap<>();
        final Throwable t = assertThrows(
            ConfigException.class,
            () -> new GcsSinkConfig(properties));
        assertEquals("Missing required configuration \"gcs.bucket.name\" which has no default value.", t.getMessage());
    }

    @Test
    void emptyGcsBucketName() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "");
        final Throwable t = assertThrows(
            ConfigException.class,
            () -> new GcsSinkConfig(properties));
        assertEquals("Invalid value  for configuration gcs.bucket.name: String must be non-empty", t.getMessage());
    }

    @Test
    void correctMinimalConfig() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");

        final GcsSinkConfig config = new GcsSinkConfig(properties);
        assertEquals("test-bucket", config.getBucketName());
        assertEquals(CompressionType.NONE, config.getCompressionType());
        assertEquals("", config.getPrefix());
        assertEquals("a-b-c",
            config.getFilenameTemplate()
                .instance()
                .bindVariable("topic", () -> "a")
                .bindVariable("partition", () -> "b")
                .bindVariable("start_offset", () -> "c")
                .render());
        assertIterableEquals(Collections.singleton(
            new OutputField(OutputFieldType.VALUE, OutputFieldEncodingType.BASE64)), config.getOutputFields());
    }

    @ParameterizedTest
    @ValueSource(strings = {"none", "gzip", "snappy", "zstd"})
    void correctFullConfig(final String compression) {
        final Map<String, String> properties = new HashMap<>();
        properties.put(
            "gcs.credentials.path",
            getClass().getClassLoader().getResource("test_gcs_credentials.json").getPath());
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.compression.type", compression);
        properties.put("file.name.prefix", "test-prefix");
        properties.put("file.name.template", "{{topic}}-{{partition}}-{{start_offset}}-{{timestamp:unit=YYYY}}.gz");
        properties.put("file.max.records", "42");
        properties.put("format.output.fields", "key,value,offset,timestamp");
        properties.put("format.output.fields.value.encoding", "base64");

        final GcsSinkConfig config = new GcsSinkConfig(properties);
        assertDoesNotThrow(config::getCredentials);
        assertEquals("test-bucket", config.getBucketName());
        assertEquals(CompressionType.forName(compression), config.getCompressionType());
        assertEquals(42, config.getMaxRecordsPerFile());
        assertEquals("test-prefix", config.getPrefix());
        assertEquals("a-b-c-d.gz",
            config.getFilenameTemplate()
                .instance()
                .bindVariable("topic", () -> "a")
                .bindVariable("partition", () -> "b")
                .bindVariable("start_offset", () -> "c")
                .bindVariable("timestamp", () -> "d")
                .render());
        assertIterableEquals(Arrays.asList(
            new OutputField(OutputFieldType.KEY, OutputFieldEncodingType.NONE),
            new OutputField(OutputFieldType.VALUE, OutputFieldEncodingType.BASE64),
            new OutputField(OutputFieldType.OFFSET, OutputFieldEncodingType.NONE),
            new OutputField(OutputFieldType.TIMESTAMP, OutputFieldEncodingType.NONE)), config.getOutputFields());

        assertEquals(ZoneOffset.UTC, config.getFilenameTimezone());
        assertEquals(
            TimestampSource.WallclockTimestampSource.class,
            config.getFilenameTimestampSource().getClass()
        );

    }

    @ParameterizedTest
    @NullSource
    @ValueSource(strings = {"none", "gzip", "snappy", "zstd"})
    void supportedCompression(final String compression) {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        if (compression != null) {
            properties.put("file.compression.type", compression);
        }

        final GcsSinkConfig config = new GcsSinkConfig(properties);
        final CompressionType expectedCompressionType =
                compression == null ? CompressionType.NONE : CompressionType.forName(compression);
        assertEquals(expectedCompressionType, config.getCompressionType());
    }

    @Test
    void unsupportedCompressionType() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.compression.type", "unsupported");

        final Throwable t = assertThrows(
            ConfigException.class, () -> new GcsSinkConfig(properties)
        );
        assertEquals("Invalid value unsupported for configuration file.compression.type: "
                + "supported values are: 'none', 'gzip', 'snappy', 'zstd'",
            t.getMessage());
    }

    @Test
    void emptyOutputField() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("format.output.fields", "");

        final Throwable t = assertThrows(
            ConfigException.class, () -> new GcsSinkConfig(properties)
        );
        assertEquals("Invalid value [] for configuration format.output.fields: cannot be empty",
            t.getMessage());
    }

    @Test
    void unsupportedOutputField() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("format.output.fields", "key,value,offset,timestamp,unsupported");

        final Throwable t = assertThrows(
            ConfigException.class, () -> new GcsSinkConfig(properties)
        );
        assertEquals("Invalid value [key, value, offset, timestamp, unsupported] "
                + "for configuration format.output.fields: "
                + "supported values are: 'key', 'value', 'offset', 'timestamp'",
            t.getMessage());
    }

    @Test
    void gcsCredentialsPath() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put(
            "gcs.credentials.path",
            getClass().getClassLoader().getResource("test_gcs_credentials.json").getPath());

        final GcsSinkConfig config = new GcsSinkConfig(properties);
        final UserCredentials credentials = (UserCredentials) config.getCredentials();
        assertEquals("test-client-id", credentials.getClientId());
        assertEquals("test-client-secret", credentials.getClientSecret());
    }

    @Test
    void gcsCredentialsJson() throws IOException {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");

        final String credentialsJson = Resources.toString(
            getClass().getClassLoader().getResource("test_gcs_credentials.json"),
            StandardCharsets.UTF_8
        );
        properties.put("gcs.credentials.json", credentialsJson);

        final GcsSinkConfig config = new GcsSinkConfig(properties);
        final UserCredentials credentials = (UserCredentials) config.getCredentials();
        assertEquals("test-client-id", credentials.getClientId());
        assertEquals("test-client-secret", credentials.getClientSecret());
    }

    @Test
    void gcsCredentialsExclusivity() throws IOException {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");

        final URL credentialsResource = getClass().getClassLoader().getResource("test_gcs_credentials.json");
        final String credentialsJson = Resources.toString(credentialsResource, StandardCharsets.UTF_8);
        properties.put("gcs.credentials.json", credentialsJson);
        properties.put("gcs.credentials.path", credentialsResource.getPath());

        final Throwable t = assertThrows(ConfigException.class, () -> new GcsSinkConfig(properties));
        assertEquals(
            "\"gcs.credentials.path\" and \"gcs.credentials.json\" are mutually exclusive options, but both are set.",
            t.getMessage());
    }

    @Test
    void connectorName() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("name", "test-connector");

        final GcsSinkConfig config = new GcsSinkConfig(properties);
        assertEquals("test-connector", config.getConnectorName());
    }

    @Test
    void fileNamePrefixTooLong() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        final String longString = Stream.generate(() -> "a").limit(1025).collect(Collectors.joining());
        properties.put("file.name.prefix", longString);
        final Throwable t = assertThrows(
            ConfigException.class,
            () -> new GcsSinkConfig(properties));
        assertEquals("Invalid value " + longString + " for configuration gcs.bucket.name: "
                + "cannot be longer than 1024 characters",
            t.getMessage());
    }

    @Test
    void fileNamePrefixProhibitedPrefix() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.name.prefix", ".well-known/acme-challenge/something");
        final Throwable t = assertThrows(
            ConfigException.class,
            () -> new GcsSinkConfig(properties));
        assertEquals("Invalid value .well-known/acme-challenge/something for configuration gcs.bucket.name: "
                + "cannot start with '.well-known/acme-challenge'",
            t.getMessage());
    }

    @Test
    void maxRecordsPerFileNotSet() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        final GcsSinkConfig config = new GcsSinkConfig(properties);
        assertEquals(0, config.getMaxRecordsPerFile());
    }

    @Test
    void maxRecordsPerFileSetCorrect() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.max.records", "42");
        final GcsSinkConfig config = new GcsSinkConfig(properties);
        assertEquals(42, config.getMaxRecordsPerFile());
    }

    @Test
    void maxRecordsPerFileSetIncorrect() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.max.records", "-42");
        final Throwable t = assertThrows(
            ConfigException.class,
            () -> new GcsSinkConfig(properties));
        assertEquals("Invalid value -42 for configuration file.max.records: "
                + "must be a non-negative integer number",
            t.getMessage());
    }

    @ParameterizedTest
    @NullSource
    @ValueSource(strings = {"none", "gzip", "snappy", "zstd"})
    void filenameTemplateNotSet(final String compression) {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        if (compression != null) {
            properties.put("file.compression.type", compression);
        }

        final CompressionType compressionType =
                compression == null ? CompressionType.NONE : CompressionType.forName(compression);
        final String expected = "a-b-c" + compressionType.extension();

        final GcsSinkConfig config = new GcsSinkConfig(properties);
        final String actual = config.getFilenameTemplate()
            .instance()
            .bindVariable("topic", () -> "a")
            .bindVariable("partition", () -> "b")
            .bindVariable("start_offset", () -> "c")
            .render();
        assertEquals(expected, actual);
    }

    @Test
    void topicPartitionOffsetFilenameTemplateVariablesOrder1() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.name.template", "{{topic}}-{{partition}}-{{start_offset}}");

        final GcsSinkConfig config = new GcsSinkConfig(properties);
        final String actual = config.getFilenameTemplate()
            .instance()
            .bindVariable("topic", () -> "a")
            .bindVariable("partition", () -> "b")
            .bindVariable("start_offset", () -> "c")
            .render();
        assertEquals("a-b-c", actual);
    }

    @Test
    void topicPartitionOffsetFilenameTemplateVariablesOrder2() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.name.template", "{{start_offset}}-{{partition}}-{{topic}}");

        final GcsSinkConfig config = new GcsSinkConfig(properties);
        final String actual = config.getFilenameTemplate()
            .instance()
            .bindVariable("topic", () -> "a")
            .bindVariable("partition", () -> "b")
            .bindVariable("start_offset", () -> "c")
            .render();
        assertEquals("c-b-a", actual);
    }

    @Test
    void acceptFilenameTemplateVariablesParameters() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.name.template", "{{start_offset:padding=true}}-{{partition}}-{{topic}}");
        final GcsSinkConfig config = new GcsSinkConfig(properties);
        final String actual = config.getFilenameTemplate()
            .instance()
            .bindVariable("topic", () -> "a")
            .bindVariable("partition", () -> "b")
            .bindVariable("start_offset", parameter -> {
                assertEquals("padding", parameter.name());
                assertTrue(parameter.asBoolean());
                return "c";
            })
            .render();
        assertEquals("c-b-a", actual);
    }

    @Test
    void keyFilenameTemplateVariable() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.name.template", "{{key}}");

        final GcsSinkConfig config = new GcsSinkConfig(properties);
        final String actual = config.getFilenameTemplate()
            .instance()
            .bindVariable("key", () -> "a")
            .render();
        assertEquals("a", actual);
    }

    @Test
    void emptyFilenameTemplate() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.name.template", "");

        final Throwable t = assertThrows(
            ConfigException.class,
            () -> new GcsSinkConfig(properties));
        assertEquals("Invalid value  for configuration file.name.template: "
                + "unsupported set of template variables, "
                + "supported sets are: topic,partition,start_offset,timestamp; key",
            t.getMessage());
    }

    @Test
    void filenameTemplateUnknownVariable() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.name.template", "{{ aaa }}{{ topic }}{{ partition }}{{ start_offset }}");

        final Throwable t = assertThrows(
            ConfigException.class,
            () -> new GcsSinkConfig(properties));
        assertEquals("Invalid value {{ aaa }}{{ topic }}{{ partition }}{{ start_offset }} "
                + "for configuration file.name.template: "
                + "unsupported set of template variables, "
                + "supported sets are: topic,partition,start_offset,timestamp; key",
            t.getMessage());
    }

    @Test
    void filenameTemplateNoTopic() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.name.template", "{{ partition }}{{ start_offset }}");

        final Throwable t = assertThrows(
            ConfigException.class,
            () -> new GcsSinkConfig(properties));
        assertEquals("Invalid value {{ partition }}{{ start_offset }} for configuration file.name.template: "
                + "unsupported set of template variables, "
                + "supported sets are: topic,partition,start_offset,timestamp; key",
            t.getMessage());
    }

    @Test
    void wrongVariableParameterValue() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.name.template", "{{start_offset:padding=FALSE}}-{{partition}}-{{topic}}");
        final Throwable t = assertThrows(
            ConfigException.class,
            () -> new GcsSinkConfig(properties)
        );
        assertEquals(
            "Invalid value {{start_offset:padding=FALSE}}-{{partition}}-{{topic}} "
                + "for configuration file.name.template: "
                + "unsupported set of template variables parameters, "
                + "supported sets are: start_offset:padding=true|false,timestamp:unit=YYYY|MM|dd|HH", t.getMessage());
    }

    @Test
    void variableWithoutRequiredParameterValue() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.name.template", "{{start_offset}}-{{partition}}-{{topic}}-{{timestamp}}");
        final Throwable t = assertThrows(
            ConfigException.class,
            () -> new GcsSinkConfig(properties)
        );
        assertEquals(
            "Invalid value {{start_offset}}-{{partition}}-{{topic}}-{{timestamp}} "
                + "for configuration file.name.template: "
                + "parameter unit is required for the the variable timestamp, "
                + "supported values are: YYYY|MM|dd|HH", t.getMessage());
    }

    @Test
    void wrongVariableWithoutParameter() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.name.template", "{{start_offset:}}-{{partition}}-{{topic}}");
        final Throwable t = assertThrows(
            ConfigException.class,
            () -> new GcsSinkConfig(properties)
        );
        assertEquals(
            "Invalid value {{start_offset:}}-{{partition}}-{{topic}} "
                + "for configuration file.name.template: "
                + "Wrong variable with parameter definition", t.getMessage());
    }

    @Test
    void noVariableWithParameter() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.name.template", "{{:padding=true}}-{{partition}}-{{topic}}");
        final Throwable t = assertThrows(
            ConfigException.class,
            () -> new GcsSinkConfig(properties)
        );
        assertEquals(
            "Invalid value {{:padding=true}}-{{partition}}-{{topic}} "
                + "for configuration file.name.template: "
                + "Variable name has't been set for template: {{:padding=true}}-{{partition}}-{{topic}}",
            t.getMessage()
        );
    }

    @Test
    void wrongVariableWithoutParameterValue() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.name.template", "{{start_offset:padding=}}-{{partition}}-{{topic}}");
        final Throwable t = assertThrows(
            ConfigException.class,
            () -> new GcsSinkConfig(properties)
        );
        assertEquals(
            "Invalid value {{start_offset:padding=}}-{{partition}}-{{topic}} "
                + "for configuration file.name.template: "
                + "Parameter value for variable `start_offset` and parameter `padding` has not been set",
            t.getMessage()
        );
    }

    @Test
    void wrongVariableWithoutParameterName() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.name.template", "{{start_offset:=true}}-{{partition}}-{{topic}}");
        final Throwable t = assertThrows(
            ConfigException.class,
            () -> new GcsSinkConfig(properties)
        );
        assertEquals(
            "Invalid value {{start_offset:=true}}-{{partition}}-{{topic}} "
                + "for configuration file.name.template: "
                + "Parameter name for variable `start_offset` has not been set", t.getMessage());
    }

    @Test
    void filenameTemplateNoPartition() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.name.template", "{{ topic }}{{ start_offset }}");

        final Throwable t = assertThrows(
            ConfigException.class,
            () -> new GcsSinkConfig(properties));
        assertEquals("Invalid value {{ topic }}{{ start_offset }} for configuration file.name.template: "
                + "unsupported set of template variables, "
                + "supported sets are: topic,partition,start_offset,timestamp; key",
            t.getMessage());
    }

    @Test
    void filenameTemplateNoStartOffset() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.name.template", "{{ topic }}{{ partition }}");

        final Throwable t = assertThrows(
            ConfigException.class,
            () -> new GcsSinkConfig(properties));
        assertEquals("Invalid value {{ topic }}{{ partition }} for configuration file.name.template: "
                + "unsupported set of template variables, "
                + "supported sets are: topic,partition,start_offset,timestamp; key",
            t.getMessage());
    }

    @Test
    void keyFilenameTemplateAndLimitedRecordsPerFileNotSet() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.name.template", "{{key}}");

        assertDoesNotThrow(() -> new GcsSinkConfig(properties));
    }

    @Test
    void keyFilenameTemplateAndLimitedRecordsPerFile1() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.name.template", "{{key}}");
        properties.put("file.max.records", "1");

        assertDoesNotThrow(() -> new GcsSinkConfig(properties));
    }

    @Test
    void keyFilenameTemplateAndLimitedRecordsPerFileMoreThan1() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put("file.name.template", "{{key}}");
        properties.put("file.max.records", "42");

        final Throwable t = assertThrows(
            ConfigException.class,
            () -> new GcsSinkConfig(properties));
        assertEquals("When file.name.template is {{key}}, file.max.records must be either 1 or not set",
            t.getMessage());
    }

    @Test
    void correctShortFilenameTimezone() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put(GcsSinkConfig.FILE_NAME_TIMESTAMP_TIMEZONE, "CET");

        final GcsSinkConfig c = new GcsSinkConfig(properties);
        assertEquals(ZoneId.of("CET"), c.getFilenameTimezone());
    }

    @Test
    void correctLongFilenameTimezone() {
        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put(GcsSinkConfig.FILE_NAME_TIMESTAMP_TIMEZONE, "Europe/Berlin");

        final GcsSinkConfig c = new GcsSinkConfig(properties);
        assertEquals(ZoneId.of("Europe/Berlin"), c.getFilenameTimezone());
    }

    @Test
    void wrongFilenameTimestampSource() {

        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put(GcsSinkConfig.FILE_NAME_TIMESTAMP_TIMEZONE, "Europe/Berlin");
        properties.put(GcsSinkConfig.FILE_NAME_TIMESTAMP_SOURCE, "UNKNOWN_TIMESTAMP_SOURCE");

        final Throwable t =
            assertThrows(
                ConfigException.class,
                () -> new GcsSinkConfig(properties)
            );
        assertEquals(
            "Invalid value UNKNOWN_TIMESTAMP_SOURCE for configuration "
                + "file.name.timestamp.source: Unknown timestamp source: UNKNOWN_TIMESTAMP_SOURCE",
            t.getMessage()
        );

    }

    @Test
    void correctFilenameTimestampSource() {

        final Map<String, String> properties = new HashMap<>();
        properties.put("gcs.bucket.name", "test-bucket");
        properties.put(GcsSinkConfig.FILE_NAME_TIMESTAMP_TIMEZONE, "Europe/Berlin");
        properties.put(GcsSinkConfig.FILE_NAME_TIMESTAMP_SOURCE, "wallclock");

        final GcsSinkConfig c = new GcsSinkConfig(properties);
        assertEquals(TimestampSource.WallclockTimestampSource.class, c.getFilenameTimestampSource().getClass());

    }

}