/* Licensed under Apache-2.0 */
package cricket.jmoore.kafka.connect.transforms;

import static cricket.jmoore.kafka.connect.transforms.SchemaRegistryTransfer.ConfigName;
import static org.apache.avro.Schema.Type.BOOLEAN;
import static org.apache.avro.Schema.Type.INT;
import static org.apache.avro.Schema.Type.STRING;
import static org.junit.jupiter.api.Assertions.*;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

import org.apache.avro.SchemaBuilder;
import org.apache.avro.generic.GenericData;
import org.apache.avro.generic.GenericDatumWriter;
import org.apache.avro.generic.GenericRecordBuilder;
import org.apache.avro.io.BinaryEncoder;
import org.apache.avro.io.DatumWriter;
import org.apache.avro.io.EncoderFactory;
import org.apache.kafka.common.errors.SerializationException;
import org.apache.kafka.connect.connector.ConnectRecord;
import org.apache.kafka.connect.data.Schema;
import org.apache.kafka.connect.errors.ConnectException;
import org.apache.kafka.connect.source.SourceRecord;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import io.confluent.kafka.schemaregistry.client.SchemaMetadata;
import io.confluent.kafka.schemaregistry.client.SchemaRegistryClient;
import io.confluent.kafka.schemaregistry.client.rest.exceptions.RestClientException;
import io.confluent.kafka.serializers.NonRecordContainer;

@SuppressWarnings("unchecked")
public class TransformTest {

    private enum ExplicitAuthType {
        USER_INFO,
        URL,
        NULL;
    }

    private static final Logger log = LoggerFactory.getLogger(TransformTest.class);

    public static final String HELLO_WORLD_VALUE = "Hello, world!";

    public static final String TOPIC = TransformTest.class.getSimpleName();

    private static final byte MAGIC_BYTE = (byte) 0x0;
    public static final int ID_SIZE = Integer.SIZE / Byte.SIZE;
    private static final int AVRO_CONTENT_OFFSET = 1 + ID_SIZE;
    public static final org.apache.avro.Schema INT_SCHEMA = org.apache.avro.Schema.create(INT);
    public static final org.apache.avro.Schema STRING_SCHEMA = org.apache.avro.Schema.create(STRING);
    public static final org.apache.avro.Schema BOOLEAN_SCHEMA = org.apache.avro.Schema.create(BOOLEAN);
    public static final org.apache.avro.Schema NAME_SCHEMA = SchemaBuilder.record("FullName")
            .namespace("cricket.jmoore.kafka.connect.transforms").fields()
            .requiredString("first")
            .requiredString("last")
            .endRecord();
    public static final org.apache.avro.Schema NAME_SCHEMA_ALIASED = SchemaBuilder.record("FullName")
            .namespace("cricket.jmoore.kafka.connect.transforms").fields()
            .requiredString("first")
            .name("surname").aliases("last").type().stringType().noDefault()
            .endRecord();

    @RegisterExtension
    final SchemaRegistryMock sourceSchemaRegistry =
        new SchemaRegistryMock(SchemaRegistryMock.Role.SOURCE);

    @RegisterExtension
    final SchemaRegistryMock destSchemaRegistry =
        new SchemaRegistryMock(SchemaRegistryMock.Role.DESTINATION);

    private SchemaRegistryTransfer smt;
    private Map<String, Object> smtConfiguration;

    private ConnectRecord createRecord(Schema keySchema, Object key, Schema valueSchema, Object value) {
        // partition and offset aren't needed
        return new SourceRecord(null, null, TOPIC, keySchema, key, valueSchema, value);
    }

    private ConnectRecord createRecord(byte[] key, byte[] value) {
        return createRecord(Schema.OPTIONAL_BYTES_SCHEMA, key, Schema.OPTIONAL_BYTES_SCHEMA, value);
    }

    private Map<String, Object> getRequiredTransformConfigs() {
        Map<String, Object> configs = new HashMap<>();
        configs.put(ConfigName.SRC_SCHEMA_REGISTRY_URL, sourceSchemaRegistry.getUrl());
        configs.put(ConfigName.DEST_SCHEMA_REGISTRY_URL, destSchemaRegistry.getUrl());
        return configs;
    }

    private void configure(boolean copyKeys) {
        smtConfiguration.put(ConfigName.TRANSFER_KEYS, copyKeys);
        smt.configure(smtConfiguration);
    }

    private void configure(boolean copyKeys, boolean copyHeaders) {
        smtConfiguration.put(ConfigName.TRANSFER_KEYS, copyKeys);
        smtConfiguration.put(ConfigName.INCLUDE_HEADERS, copyHeaders);
        smt.configure(smtConfiguration);
    }

    private void configure(final String sourceUserInfo, final String destUserInfo, ExplicitAuthType credentialSource) {
        if (credentialSource == ExplicitAuthType.USER_INFO) {
            if (sourceUserInfo != null) {
                smtConfiguration.put(ConfigName.SRC_BASIC_AUTH_CREDENTIALS_SOURCE, Constants.USER_INFO_SOURCE);
                smtConfiguration.put(ConfigName.SRC_USER_INFO, sourceUserInfo);
            }

            if (destUserInfo != null) {
                smtConfiguration.put(ConfigName.DEST_BASIC_AUTH_CREDENTIALS_SOURCE, Constants.USER_INFO_SOURCE);
                smtConfiguration.put(ConfigName.DEST_USER_INFO, destUserInfo);
            }
        } else {
            if (sourceUserInfo != null) {
                String url = sourceSchemaRegistry.getUrl();
                url = url.replace("://", "://" + sourceUserInfo + "@" );
                smtConfiguration.put(ConfigName.SRC_SCHEMA_REGISTRY_URL, url);

                if (credentialSource == ExplicitAuthType.URL) {
                    smtConfiguration.put(ConfigName.SRC_BASIC_AUTH_CREDENTIALS_SOURCE, Constants.URL_SOURCE);
                } else if (credentialSource == ExplicitAuthType.NULL) {
                    // For an explicit null case, set both the URL and UserInfo to confirm that neither is found
                    smtConfiguration.put(ConfigName.SRC_BASIC_AUTH_CREDENTIALS_SOURCE, null);
                    smtConfiguration.put(ConfigName.SRC_USER_INFO, sourceUserInfo);
                } else {
                    // For null ExplicitAuthType, insert no key and rely on implicit default.
                }
            }

            if (destUserInfo != null) {
                String url = destSchemaRegistry.getUrl();
                url = url.replace("://", "://" + destUserInfo + "@" );
                smtConfiguration.put(ConfigName.DEST_SCHEMA_REGISTRY_URL, url);

                if (credentialSource == ExplicitAuthType.URL) {
                    smtConfiguration.put(ConfigName.DEST_BASIC_AUTH_CREDENTIALS_SOURCE, Constants.URL_SOURCE);
                } else if (credentialSource == ExplicitAuthType.NULL) {
                    // For an explicit null case, set both the URL and UserInfo to confirm that neither is found
                    smtConfiguration.put(ConfigName.DEST_BASIC_AUTH_CREDENTIALS_SOURCE, null);
                    smtConfiguration.put(ConfigName.DEST_USER_INFO, destUserInfo);
                } else {
                    // For null ExplicitAuthType, insert no key and rely on implicit default.
                }
            }
        }

        smt.configure(smtConfiguration);
    }

    private ByteArrayOutputStream encodeAvroObject(org.apache.avro.Schema schema, int sourceId, Object datum) throws IOException {
        ByteArrayOutputStream out = new ByteArrayOutputStream();

        out.write(MAGIC_BYTE);
        out.write(ByteBuffer.allocate(ID_SIZE).putInt(sourceId).array());

        EncoderFactory encoderFactory = EncoderFactory.get();
        BinaryEncoder encoder = encoderFactory.directBinaryEncoder(out, null);
        Object
                value =
                datum instanceof NonRecordContainer ? ((NonRecordContainer) datum).getValue()
                        : datum;
        DatumWriter<Object> writer = new GenericDatumWriter<>(schema);
        writer.write(value, encoder);
        encoder.flush();

        return out;
    }

    // Used to run a message through the SMT when testing authentication modes, which only need to
    // know if there was a communication error, but rely on other tests to verify schema transfers
    // are making the correct API calls.
    private void passSimpleMessage() throws IOException {
        // Create key/value schemas for source registry
        log.info("Registering key/value string schemas in source registry");
        final int sourceKeyId = sourceSchemaRegistry.registerSchema(TOPIC, true, STRING_SCHEMA);
        final int sourceValId = sourceSchemaRegistry.registerSchema(TOPIC, false, STRING_SCHEMA);

        final ByteArrayOutputStream keyOut =
            encodeAvroObject(STRING_SCHEMA, sourceKeyId, HELLO_WORLD_VALUE);
        final ByteArrayOutputStream valOut =
            encodeAvroObject(STRING_SCHEMA, sourceValId, HELLO_WORLD_VALUE);
        final ConnectRecord record =
            createRecord(keyOut.toByteArray(), valOut.toByteArray());

        smt.apply(record);
    }

    @BeforeEach
    public void setup() {
        smt = new SchemaRegistryTransfer();
        smtConfiguration = getRequiredTransformConfigs();
    }

    @Test
    public void applyKeySchemaNotBytes() {
        configure(true);

        ConnectRecord record = createRecord(null, null, null, null);

        // The key schema is not a byte[]
        assertThrows(ConnectException.class, () -> smt.apply(record));
    }

    @Test
    public void applyValueSchemaNotBytes() {
        configure(false);

        ConnectRecord record = createRecord(null, null, null, null);

        // The value schema is not a byte[]
        assertThrows(ConnectException.class, () -> smt.apply(record));
    }

    @Test
    public void applySchemalessKeyBytesTooShort() {
        configure(true);

        // allocate enough space for the magic-byte
        byte[] b = ByteBuffer.allocate(1).array();
        ConnectRecord record = createRecord(null, b, null, null);

        // The key payload is not long enough for schema registry wire-format
        assertThrows(SerializationException.class, () -> smt.apply(record));
    }

    @Test
    public void applySchemalessValueBytesTooShort() {
        configure(false);

        // allocate enough space for the magic-byte
        byte[] b = ByteBuffer.allocate(1).array();
        ConnectRecord record = createRecord(null, null, null, b);

        // The value payload is not long enough for schema registry wire-format
        assertThrows(SerializationException.class, () -> smt.apply(record));
    }

    @Test
    public void testKeySchemaLookupFailure() {
        configure(true);

        byte[] b = ByteBuffer.allocate(6).array();
        ConnectRecord record = createRecord(null, b, null, null);

        // tries to lookup schema id 0, but that isn't a valid id
        assertThrows(ConnectException.class, () -> smt.apply(record));
    }

    @Test
    public void testValueSchemaLookupFailure() {
        configure(false);

        byte[] b = ByteBuffer.allocate(6).array();
        ConnectRecord record = createRecord(null, null, null, b);

        // tries to lookup schema id 0, but that isn't a valid id
        assertThrows(ConnectException.class, () -> smt.apply(record));
    }

    @Test
    @Tag(Constants.USE_BASIC_AUTH_SOURCE_TAG)
    @Tag(Constants.USE_BASIC_AUTH_DEST_TAG)
    public void testBothBasicHttpAuthUserInfo() throws IOException {
        configure(
                Constants.HTTP_AUTH_SOURCE_CREDENTIALS_FIXTURE,
                Constants.HTTP_AUTH_DEST_CREDENTIALS_FIXTURE,
                ExplicitAuthType.USER_INFO);

        this.passSimpleMessage();
    }


    @Test
    @Tag(Constants.USE_BASIC_AUTH_SOURCE_TAG)
    public void testSourceBasicHttpAuthUserInfo() throws IOException {
        configure(Constants.HTTP_AUTH_SOURCE_CREDENTIALS_FIXTURE, null, ExplicitAuthType.USER_INFO);

        this.passSimpleMessage();
    }

    @Test
    @Tag(Constants.USE_BASIC_AUTH_DEST_TAG)
    public void testDestinationBasicHttpAuthUserInfo() throws IOException {
        configure(null, Constants.HTTP_AUTH_DEST_CREDENTIALS_FIXTURE, ExplicitAuthType.USER_INFO);

        this.passSimpleMessage();
    }

    @Test
    @Tag(Constants.USE_BASIC_AUTH_SOURCE_TAG)
    public void testSourceBasicHttpAuthUrl() throws IOException {
        configure(Constants.HTTP_AUTH_SOURCE_CREDENTIALS_FIXTURE, null, ExplicitAuthType.URL);

        this.passSimpleMessage();
    }

    @Test
    @Tag(Constants.USE_BASIC_AUTH_DEST_TAG)
    public void testDestinationBasicHttpAuthUrl() throws IOException {
        configure(null, Constants.HTTP_AUTH_DEST_CREDENTIALS_FIXTURE, ExplicitAuthType.URL);

        this.passSimpleMessage();
    }

    @Test
    @Tag(Constants.USE_BASIC_AUTH_SOURCE_TAG)
    public void testSourceBasicHttpAuthNull() throws IOException {
        configure(Constants.HTTP_AUTH_SOURCE_CREDENTIALS_FIXTURE, null, ExplicitAuthType.NULL);

       assertThrows(ConnectException.class, () -> this.passSimpleMessage());
    }

    @Test
    @Tag(Constants.USE_BASIC_AUTH_DEST_TAG)
    public void testDestinationBasicHttpAuthNull() throws IOException {
        configure(null, Constants.HTTP_AUTH_DEST_CREDENTIALS_FIXTURE, ExplicitAuthType.NULL);

        assertThrows(ConnectException.class, () -> this.passSimpleMessage());
    }

    @Test
    @Tag(Constants.USE_BASIC_AUTH_SOURCE_TAG)
    public void testSourceBasicHttpAuthImplicitDefault() throws IOException {
        configure(Constants.HTTP_AUTH_SOURCE_CREDENTIALS_FIXTURE, null, null);

        this.passSimpleMessage();
    }

    @Test
    @Tag(Constants.USE_BASIC_AUTH_DEST_TAG)
    public void testDestinationBasicHttpAuthImplicitDefault() throws IOException {
        configure(null, Constants.HTTP_AUTH_DEST_CREDENTIALS_FIXTURE, null);

        this.passSimpleMessage();
    }

    @Test
    @Tag(Constants.USE_BASIC_AUTH_SOURCE_TAG)
    public void testSourceBasicHttpAuthWrong() throws IOException {
        configure(Constants.HTTP_AUTH_DEST_CREDENTIALS_FIXTURE, null, null);

        assertThrows(ConnectException.class, () -> this.passSimpleMessage());
    }

    @Test
    @Tag(Constants.USE_BASIC_AUTH_DEST_TAG)
    public void testDestinationBasicHttpAuthWrong() throws IOException {
        configure(null, Constants.HTTP_AUTH_SOURCE_CREDENTIALS_FIXTURE, null);

        assertThrows(ConnectException.class, () -> this.passSimpleMessage());
    }

    @Test
    @Tag(Constants.USE_BASIC_AUTH_SOURCE_TAG)
    public void testSourceBasicHttpAuthOmit() throws IOException {
        configure(null, null, null);

        assertThrows(ConnectException.class, () -> this.passSimpleMessage());
    }

    @Test
    @Tag(Constants.USE_BASIC_AUTH_DEST_TAG)
    public void testDestinationBasicHttpAuthOmit() throws IOException {
        configure(null, null, null);

        assertThrows(ConnectException.class, () -> this.passSimpleMessage());
    }

    @Test
    public void testKeySchemaTransfer() {
        configure(true);

        // Create bogus schema in destination so that source and destination ids differ
        log.info("Registering schema in destination registry");
        destSchemaRegistry.registerSchema(UUID.randomUUID().toString(), true, INT_SCHEMA);

        // Create new schema for source registry
        org.apache.avro.Schema schema = STRING_SCHEMA;
        log.info("Registering schema in source registry");
        int sourceId = sourceSchemaRegistry.registerSchema(TOPIC, true, schema);
        final String subject = TOPIC + "-key";
        assertEquals(1, sourceId, "An empty registry starts at id=1");

        SchemaRegistryClient sourceClient = sourceSchemaRegistry.getSchemaRegistryClient();
        int numSourceVersions = 0;
        try {
            numSourceVersions = sourceClient.getAllVersions(subject).size();
            assertEquals(1, numSourceVersions, "the source registry subject contains the pre-registered schema");
        } catch (IOException | RestClientException e) {
            fail(e);
        }

        try {
            ByteArrayOutputStream out = encodeAvroObject(schema, sourceId, "hello, world");

            ConnectRecord record = createRecord(Schema.OPTIONAL_BYTES_SCHEMA, out.toByteArray(), null, null);

            // check the destination has no versions for this subject
            SchemaRegistryClient destClient = destSchemaRegistry.getSchemaRegistryClient();
            List<Integer> destVersions = destClient.getAllVersions(subject);
            assertTrue(destVersions.isEmpty(), "the destination registry starts empty");

            // The transform will fail on the byte[]-less record value.
            // TODO: Allow only key schemas to be copied?
            log.info("applying transformation");
            ConnectException connectException = assertThrows(ConnectException.class, () -> smt.apply(record));
            assertEquals("Transform failed. Record value does not have a byte[] schema.", connectException.getMessage());

            // In any case, we can still check the key schema was copied, and the destination now has some version
            destVersions = destClient.getAllVersions(subject);
            assertEquals(numSourceVersions, destVersions.size(),
                    "source and destination registries have the same amount of schemas for the same subject");

            // Verify that the ids for the source and destination are different
            SchemaMetadata metadata = destClient.getSchemaMetadata(subject, destVersions.get(0));
            int destinationId = metadata.getId();
            log.debug("source_id={} ; dest_id={}", sourceId, destinationId);
            assertTrue(sourceId < destinationId,
                    "destination id should be different and higher since that registry already had schemas");

            // Verify the schema is the same
            org.apache.avro.Schema sourceSchema = sourceClient.getById(sourceId);
            org.apache.avro.Schema destSchema = new org.apache.avro.Schema.Parser().parse(metadata.getSchema());
            assertEquals(schema, sourceSchema, "source server returned same schema");
            assertEquals(schema, destSchema, "destination server returned same schema");
            assertEquals(sourceSchema, destSchema, "both servers' schemas match");
        } catch (IOException | RestClientException e) {
            fail(e);
        }
    }

    @Test
    public void testValueSchemaTransfer() {
        configure(true);

        // Create bogus schema in destination so that source and destination ids differ
        log.info("Registering schema in destination registry");
        destSchemaRegistry.registerSchema(UUID.randomUUID().toString(), false, INT_SCHEMA);

        // Create new schema for source registry
        org.apache.avro.Schema schema = STRING_SCHEMA;
        log.info("Registering schema in source registry");
        int sourceId = sourceSchemaRegistry.registerSchema(TOPIC, false, schema);
        final String subject = TOPIC + "-value";
        assertEquals(1, sourceId, "An empty registry starts at id=1");

        SchemaRegistryClient sourceClient = sourceSchemaRegistry.getSchemaRegistryClient();
        int numSourceVersions = 0;
        try {
            numSourceVersions = sourceClient.getAllVersions(subject).size();
            assertEquals(1, numSourceVersions, "the source registry subject contains the pre-registered schema");
        } catch (IOException | RestClientException e) {
            fail(e);
        }

        byte[] value = null;
        ConnectRecord appliedRecord = null;
        int destinationId = -1;
        try {
            ByteArrayOutputStream out = encodeAvroObject(schema, sourceId, "hello, world");

            value = out.toByteArray();
            ConnectRecord record = createRecord(null, value);

            // check the destination has no versions for this subject
            SchemaRegistryClient destClient = destSchemaRegistry.getSchemaRegistryClient();
            List<Integer> destVersions = destClient.getAllVersions(subject);
            assertTrue(destVersions.isEmpty(), "the destination registry starts empty");

            // The transform will pass for key and value with byte schemas
            log.info("applying transformation");
            appliedRecord = assertDoesNotThrow(() -> smt.apply(record));

            assertEquals(record.keySchema(), appliedRecord.keySchema(), "key schema unchanged");
            assertEquals(record.key(), appliedRecord.key(), "null key not modified");
            assertEquals(record.valueSchema(), appliedRecord.valueSchema(), "value schema unchanged");

            // check the value schema was copied, and the destination now has some version
            destVersions = destClient.getAllVersions(subject);
            assertEquals(numSourceVersions, destVersions.size(),
                    "source and destination registries have the same amount of schemas for the same subject");

            // Verify that the ids for the source and destination are different
            SchemaMetadata metadata = destClient.getSchemaMetadata(subject, destVersions.get(0));
            destinationId = metadata.getId();
            log.debug("source_id={} ; dest_id={}", sourceId, destinationId);
            assertTrue(sourceId < destinationId,
                    "destination id should be different and higher since that registry already had schemas");

            // Verify the schema is the same
            org.apache.avro.Schema sourceSchema = sourceClient.getById(sourceId);
            org.apache.avro.Schema destSchema = new org.apache.avro.Schema.Parser().parse(metadata.getSchema());
            assertEquals(schema, sourceSchema, "source server returned same schema");
            assertEquals(schema, destSchema, "destination server returned same schema");
            assertEquals(sourceSchema, destSchema, "both servers' schemas match");
        } catch (IOException | RestClientException e) {
            fail(e);
        }

        // Verify the record's byte value was transformed, and avro content is same
        byte[] appliedValue = (byte[]) appliedRecord.value();
        ByteBuffer appliedValueBuffer = ByteBuffer.wrap(appliedValue);
        assertEquals(value.length, appliedValue.length, "byte[] values sizes unchanged");
        assertEquals(MAGIC_BYTE, appliedValueBuffer.get(), "record value starts with magic byte");
        int transformedRecordSchemaId = appliedValueBuffer.getInt();
        assertNotEquals(sourceId, transformedRecordSchemaId, "transformed record's schema id changed");
        assertEquals(destinationId, transformedRecordSchemaId, "record value's schema id matches destination id");
        assertArrayEquals(Arrays.copyOfRange(value, AVRO_CONTENT_OFFSET, value.length),
                Arrays.copyOfRange(appliedValueBuffer.array(), AVRO_CONTENT_OFFSET, appliedValue.length),
                "the avro data is not modified");
    }

    @Test
    public void testKeyValueSchemaTransfer() {
        configure(true);

        // Create bogus schema in destination so that source and destination ids differ
        log.info("Registering schema in destination registry");
        destSchemaRegistry.registerSchema(UUID.randomUUID().toString(), false, BOOLEAN_SCHEMA);

        // Create new schemas for source registry
        org.apache.avro.Schema keySchema = INT_SCHEMA;
        org.apache.avro.Schema valueSchema = STRING_SCHEMA;
        log.info("Registering schemas in source registry");
        int sourceKeyId = sourceSchemaRegistry.registerSchema(TOPIC, true, keySchema);
        final String keySubject = TOPIC + "-key";
        assertEquals(1, sourceKeyId, "An empty registry starts at id=1");
        int sourceValueId = sourceSchemaRegistry.registerSchema(TOPIC, false, valueSchema);
        final String valueSubject = TOPIC + "-value";
        assertEquals(2, sourceValueId, "unique schema ids monotonically increase");

        SchemaRegistryClient sourceClient = sourceSchemaRegistry.getSchemaRegistryClient();
        int numSourceKeyVersions = 0;
        int numSourceValueVersions = 0;
        try {
            numSourceKeyVersions = sourceClient.getAllVersions(keySubject).size();
            assertEquals(1, numSourceKeyVersions, "the source registry subject contains the pre-registered key schema");
            numSourceValueVersions = sourceClient.getAllVersions(valueSubject).size();
            assertEquals(1, numSourceValueVersions, "the source registry subject contains the pre-registered value schema");
        } catch (IOException | RestClientException e) {
            fail(e);
        }

        byte[] key = null;
        byte[] value = null;
        ConnectRecord appliedRecord = null;
        int destinationKeyId = -1;
        int destinationValueId = -1;
        try {
            ByteArrayOutputStream keyStream = encodeAvroObject(keySchema, sourceKeyId, AVRO_CONTENT_OFFSET);
            ByteArrayOutputStream valueStream = encodeAvroObject(valueSchema, sourceValueId, "hello, world");

            key = keyStream.toByteArray();
            value = valueStream.toByteArray();
            ConnectRecord record = createRecord(key, value);

            // check the destination has no versions for this subject
            SchemaRegistryClient destClient = destSchemaRegistry.getSchemaRegistryClient();
            List<Integer> destKeyVersions = destClient.getAllVersions(keySubject);
            assertTrue(destKeyVersions.isEmpty(), "the destination registry starts empty");
            List<Integer> destValueVersions = destClient.getAllVersions(valueSubject);
            assertTrue(destValueVersions.isEmpty(), "the destination registry starts empty");

            // The transform will pass for key and value with byte schemas
            log.info("applying transformation");
            appliedRecord = assertDoesNotThrow(() -> smt.apply(record));

            assertEquals(record.keySchema(), appliedRecord.keySchema(), "key schema unchanged");
            assertEquals(record.valueSchema(), appliedRecord.valueSchema(), "value schema unchanged");

            // check the value schema was copied, and the destination now has some version
            destKeyVersions = destClient.getAllVersions(keySubject);
            assertEquals(numSourceKeyVersions, destKeyVersions.size(),
                    "source and destination registries have the same amount of schemas for the key subject");
            destValueVersions = destClient.getAllVersions(valueSubject);
            assertEquals(numSourceValueVersions, destValueVersions.size(),
                    "source and destination registries have the same amount of schemas for the value subject");

            // Verify that the ids for the source and destination are different
            SchemaMetadata keyMetadata = destClient.getSchemaMetadata(keySubject, destKeyVersions.get(0));
            destinationKeyId = keyMetadata.getId();
            log.debug("source_keyId={} ; dest_keyId={}", sourceKeyId, destinationKeyId);
            assertTrue(sourceKeyId < destinationKeyId,
                    "destination id should be different and higher since that registry already had schemas");
            SchemaMetadata valueMetadata = destClient.getSchemaMetadata(valueSubject, destValueVersions.get(0));
            destinationValueId = valueMetadata.getId();
            log.debug("source_valueId={} ; dest_valueId={}", sourceValueId, destinationValueId);
            assertTrue(sourceValueId < destinationValueId,
                    "destination id should be different and higher since that registry already had schemas");

            // Verify the schemas are the same
            org.apache.avro.Schema sourceKeySchema = sourceClient.getById(sourceKeyId);
            org.apache.avro.Schema destKeySchema = new org.apache.avro.Schema.Parser().parse(keyMetadata.getSchema());
            assertEquals(destKeySchema, sourceKeySchema, "source server returned same key schema");
            assertEquals(keySchema, destKeySchema, "destination server returned same key schema");
            assertEquals(sourceKeySchema, destKeySchema, "both servers' key schemas match");
            org.apache.avro.Schema sourceValueSchema = sourceClient.getById(sourceValueId);
            org.apache.avro.Schema destValueSchema = new org.apache.avro.Schema.Parser().parse(valueMetadata.getSchema());
            assertEquals(destValueSchema, sourceValueSchema, "source server returned same value schema");
            assertEquals(valueSchema, destValueSchema, "destination server returned same value schema");
            assertEquals(sourceValueSchema, destValueSchema, "both servers' value schemas match");

        } catch (IOException | RestClientException e) {
            fail(e);
        }

        // Verify the record's byte key was transformed, and avro content is same
        byte[] appliedKey = (byte[]) appliedRecord.key();
        ByteBuffer appliedKeyBuffer = ByteBuffer.wrap(appliedKey);
        assertEquals(key.length, appliedKey.length, "key byte[] sizes unchanged");
        assertEquals(MAGIC_BYTE, appliedKeyBuffer.get(), "record key starts with magic byte");
        int transformedRecordKeySchemaId = appliedKeyBuffer.getInt();
        assertNotEquals(sourceKeyId, transformedRecordKeySchemaId, "transformed record's key schema id changed");
        assertEquals(destinationKeyId, transformedRecordKeySchemaId, "record key's schema id matches destination id");
        assertArrayEquals(Arrays.copyOfRange(key, AVRO_CONTENT_OFFSET, key.length),
                Arrays.copyOfRange(appliedKeyBuffer.array(), AVRO_CONTENT_OFFSET, appliedKey.length),
                "the key's avro data is not modified");

        // Verify the record's byte value was transformed, and avro content is same
        byte[] appliedValue = (byte[]) appliedRecord.value();
        ByteBuffer appliedValueBuffer = ByteBuffer.wrap(appliedValue);
        assertEquals(value.length, appliedValue.length, "value byte[] sizes unchanged");
        assertEquals(MAGIC_BYTE, appliedValueBuffer.get(), "record value starts with magic byte");
        int transformedRecordValueSchemaId = appliedValueBuffer.getInt();
        assertNotEquals(sourceValueId, transformedRecordValueSchemaId, "transformed record's schema id changed");
        assertEquals(destinationValueId, transformedRecordValueSchemaId, "record value's schema id matches destination id");
        assertArrayEquals(Arrays.copyOfRange(value, AVRO_CONTENT_OFFSET, value.length),
                Arrays.copyOfRange(appliedValueBuffer.array(), AVRO_CONTENT_OFFSET, appliedValue.length),
                "the value's avro data is not modified");
    }

    @Test
    public void testTombstoneRecord() {
        configure(false);

        ConnectRecord record = createRecord(null, null, Schema.OPTIONAL_BYTES_SCHEMA, null);

        log.info("applying transformation");
        ConnectRecord appliedRecord = assertDoesNotThrow(() -> smt.apply(record));

        assertEquals(record.valueSchema(), appliedRecord.valueSchema(), "value schema unchanged");
        assertNull(appliedRecord.value());
    }

    @Test
    public void testEvolvingValueSchemaTransfer() {
        configure(true);

        // Create bogus schema in destination so that source and destination ids differ
        log.info("Registering schema in destination registry");
        destSchemaRegistry.registerSchema(UUID.randomUUID().toString(), false, INT_SCHEMA);

        log.info("Registering schema in source registry");
        int sourceId = sourceSchemaRegistry.registerSchema(TOPIC, false, NAME_SCHEMA);
        int nextSourceId = sourceSchemaRegistry.registerSchema(TOPIC, false, NAME_SCHEMA_ALIASED);
        final String subject = TOPIC + "-value";
        assertEquals(1, sourceId, "An empty registry starts at id=1");
        assertEquals(2, nextSourceId, "The next schema is id=2");

        SchemaRegistryClient sourceClient = sourceSchemaRegistry.getSchemaRegistryClient();
        int numSourceVersions = 0;
        try {
            numSourceVersions = sourceClient.getAllVersions(subject).size();
            assertEquals(2, numSourceVersions, "the source registry subject contains the pre-registered schema");
        } catch (IOException | RestClientException e) {
            fail(e);
        }

        try {
            GenericData.Record record1 = new GenericRecordBuilder(NAME_SCHEMA)
                    .set("first", "fname")
                    .set("last", "lname")
                    .build();
            ByteArrayOutputStream out = encodeAvroObject(NAME_SCHEMA, sourceId, record1);

            byte[] value = out.toByteArray();
            ConnectRecord record = createRecord(null, value);

            GenericData.Record record2 = new GenericRecordBuilder(NAME_SCHEMA_ALIASED)
                    .set("first", "fname")
                    .set("surname", "lname")
                    .build();
            out = encodeAvroObject(NAME_SCHEMA_ALIASED, nextSourceId, record2);

            byte[] nextValue = out.toByteArray();
            ConnectRecord nextRecord = createRecord(null, nextValue);

            // check the destination has no versions for this subject
            SchemaRegistryClient destClient = destSchemaRegistry.getSchemaRegistryClient();
            List<Integer> destVersions = destClient.getAllVersions(subject);
            assertTrue(destVersions.isEmpty(), "the destination registry starts empty");

            // The transform will pass for key and value with byte schemas
            log.info("applying transformation");
            assertDoesNotThrow(() -> smt.apply(record));

            // check the value schema was copied, and the destination now has some version
            destVersions = destClient.getAllVersions(subject);
            assertEquals(1, destVersions.size(),
                    "the destination registry has been updated with first schema");

            log.info("applying transformation");
            assertDoesNotThrow(() -> smt.apply(nextRecord));

            destVersions = destClient.getAllVersions(subject);
            assertEquals(numSourceVersions, destVersions.size(),
                    "the destination registry has been updated with the second schema");

        } catch (IOException | RestClientException e) {
            fail(e);
        }
    }

    @Test
    @Disabled("TODO: Find scenario where a backwards compatible change cannot be undone")
    public void testIncompatibleEvolvingValueSchemaTransfer() {
        configure(true);

        // Create bogus schema in destination so that source and destination ids differ
        log.info("Registering schema in destination registry");
        destSchemaRegistry.registerSchema(UUID.randomUUID().toString(), false, INT_SCHEMA);

        // Create new schema for source registry
        log.info("Registering schema in source registry");

        // TODO: Figure out what these should be, where if order is flipped, destination will not accept
        org.apache.avro.Schema schema = null;
        org.apache.avro.Schema nextSchema = null;

        int sourceId = sourceSchemaRegistry.registerSchema(TOPIC, false, schema);
        int nextSourceId = sourceSchemaRegistry.registerSchema(TOPIC, false, nextSchema);
        final String subject = TOPIC + "-value";
        assertEquals(1, sourceId, "An empty registry starts at id=1");
        assertEquals(2, nextSourceId, "The next schema is id=2");

        SchemaRegistryClient sourceClient = sourceSchemaRegistry.getSchemaRegistryClient();
        int numSourceVersions = 0;
        try {
            numSourceVersions = sourceClient.getAllVersions(subject).size();
            assertEquals(2, numSourceVersions, "the source registry subject contains the pre-registered schema");
        } catch (IOException | RestClientException e) {
            fail(e);
        }

        try {
            // TODO: Depending on schemas above, then build Avro records for them
            // ensure second id is encoded first
            ByteArrayOutputStream out = encodeAvroObject(nextSchema, nextSourceId, null);

            byte[] value = out.toByteArray();
            ConnectRecord record = createRecord(null, value);

            out = encodeAvroObject(schema, sourceId, null);

            byte[] nextValue = out.toByteArray();
            ConnectRecord nextRecord = createRecord(null, nextValue);

            // check the destination has no versions for this subject
            SchemaRegistryClient destClient = destSchemaRegistry.getSchemaRegistryClient();
            List<Integer> destVersions = destClient.getAllVersions(subject);
            assertTrue(destVersions.isEmpty(), "the destination registry starts empty");

            // The transform will pass for key and value with byte schemas
            log.info("applying transformation");
            assertDoesNotThrow(() -> smt.apply(record));

            // check the value schema was copied, and the destination now has some version
            destVersions = destClient.getAllVersions(subject);
            assertEquals(1, destVersions.size(),
                    "the destination registry has been updated with first schema");

            log.info("applying transformation");
            assertThrows(ConnectException.class, () -> smt.apply(nextRecord));

        } catch (IOException | RestClientException e) {
            fail(e);
        }
    }
}