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

import static java.net.HttpURLConnection.HTTP_NOT_FOUND;

import java.io.IOException;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;

import org.apache.avro.Schema;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import io.confluent.kafka.schemaregistry.client.CachedSchemaRegistryClient;
import io.confluent.kafka.schemaregistry.client.MockSchemaRegistryClient;
import io.confluent.kafka.schemaregistry.client.SchemaMetadata;
import io.confluent.kafka.schemaregistry.client.SchemaRegistryClient;
import io.confluent.kafka.schemaregistry.client.rest.entities.Config;
import io.confluent.kafka.schemaregistry.client.rest.entities.SchemaString;
import io.confluent.kafka.schemaregistry.client.rest.entities.requests.RegisterSchemaRequest;
import io.confluent.kafka.schemaregistry.client.rest.entities.requests.RegisterSchemaResponse;
import io.confluent.kafka.schemaregistry.client.rest.exceptions.RestClientException;
import io.confluent.kafka.serializers.subject.TopicNameStrategy;
import io.confluent.kafka.serializers.subject.strategy.SubjectNameStrategy;

import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.client.MappingBuilder;
import com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder;
import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.common.FileSource;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import com.github.tomakehurst.wiremock.extension.Parameters;
import com.github.tomakehurst.wiremock.extension.ResponseDefinitionTransformer;
import com.github.tomakehurst.wiremock.http.Request;
import com.github.tomakehurst.wiremock.http.ResponseDefinition;
import com.github.tomakehurst.wiremock.stubbing.StubMapping;
import com.google.common.base.Splitter;
import com.google.common.collect.Iterables;

/**
 * <p>The schema registry mock implements a few basic HTTP endpoints that are used by the Avro serdes.</p>
 * In particular,
 * <ul>
 * <li>you can register a schema and</li>
 * <li>retrieve a schema by id.</li>
 * </ul>
 *
 * <p>Additionally, server-side mock can be toggled from its default authentication behavior (no authentication)
 * to a variant that requires basic HTTP Authentication using fixed credentials `username:password` by placing a
 * `@Tag(Constants.USE_BASIC_AUTH_SOURCE_TAG)` and/or `@Tag(Constants.USE_BASIC_AUTH_DESTR_TAG)` annotation after
 * @Test annotation of any basic HTTP authentication dependent test code.</p>
 *
 * <p>If you use the TestToplogy of the fluent Kafka Streams test, you don't have to interact with this class at
 * all.</p>
 *
 * <p>Without the test framework, you can use the mock as follows:</p>
 * <pre><code>
 * class SchemaRegistryMockTest {
 *     {@literal @RegisterExtension}
 *     final SchemaRegistryMock schemaRegistry = new SchemaRegistryMock();
 *
 *     {@literal @Test}
 *     void shouldRegisterKeySchema() throws IOException, RestClientException {
 *         final Schema keySchema = this.createSchema("key_schema");
 *         final int id = this.schemaRegistry.registerKeySchema("test-topic", keySchema);
 *
 *         final Schema retrievedSchema = this.schemaRegistry.getSchemaRegistryClient().getById(id);
 *         assertThat(retrievedSchema).isEqualTo(keySchema);
 *     }
 *
 *     {@literal @Test}
 *     {@literal @Tag(Constants.USE_BASIC_AUTH_SOURCE_TAG)}
 *     {@literal @Tag(Constants.USE_BASIC_AUTH_DEST_TAG)}
 *     void shouldUseBasicAuth() {
 *         final Map<String, Object> smtConfiguration = new HashMap<>();
 *         // ...
 *         smtConfiguration.put(ConfigName.SRC_BASIC_AUTH_CREDENTIALS_SOURCE, "USER_INFO");
 *         smtConfiguration.put(ConfigName.SRC_USER_INFO, "username:password");
 *         smtConfiguration.put(ConfigName.SRC_BASIC_AUTH_CREDENTIALS_SOURCE, "USER_INFO");
 *         smtConfiguration.put(ConfigName.SRC_USER_INFO, "username:password");
 *         // ...
 *         final SchemaRegistryTransfer<SourceRecord> smt = new SchemaRegistryTransfer<SourceRecord>();
 *         smt.configure(smtConfiguration);
 *         // ...
 *         smt.apply(...);
 *     }
 * }</code></pre>
 * <p>
 * To retrieve the url of the schema registry for a Kafka Streams config, please use {@link #getUrl()}
 */
public class SchemaRegistryMock implements BeforeEachCallback, AfterEachCallback {
    public enum Role {
        SOURCE,
        DESTINATION;
    }

    private static final String SCHEMA_REGISTRATION_PATTERN = "/subjects/[^/]+/versions";
    private static final String SCHEMA_BY_ID_PATTERN = "/schemas/ids/";
    private static final String CONFIG_PATTERN = "/config";
    private static final int IDENTITY_MAP_CAPACITY = 1000;
    private final ListVersionsHandler listVersionsHandler = new ListVersionsHandler();
    private final GetVersionHandler getVersionHandler = new GetVersionHandler();
    private final AutoRegistrationHandler autoRegistrationHandler = new AutoRegistrationHandler();
    private final GetConfigHandler getConfigHandler = new GetConfigHandler();
    private final WireMockServer mockSchemaRegistry = new WireMockServer(
            WireMockConfiguration.wireMockConfig().dynamicPort().extensions(
                    this.autoRegistrationHandler, this.listVersionsHandler, this.getVersionHandler,
                    this.getConfigHandler));
    private final SchemaRegistryClient schemaRegistryClient = new MockSchemaRegistryClient();
    private final String basicAuthTag;
    private final String basicAuthCredentials;
    private Function<MappingBuilder, StubMapping> stubFor;

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

    public SchemaRegistryMock(Role role) {
        if (role == null) {
            throw new IllegalArgumentException("Role must be either SOURCE or DESTINATION");
        }

        this.basicAuthTag = (role == Role.SOURCE) ? Constants.USE_BASIC_AUTH_SOURCE_TAG : Constants.USE_BASIC_AUTH_DEST_TAG; 
        this.basicAuthCredentials =
            (role == Role.SOURCE)? Constants.HTTP_AUTH_SOURCE_CREDENTIALS_FIXTURE : Constants.HTTP_AUTH_DEST_CREDENTIALS_FIXTURE;
    }

    @Override
    public void afterEach(final ExtensionContext context) {
        this.mockSchemaRegistry.stop();
    }

    @Override
    public void beforeEach(final ExtensionContext context) {
        if (context.getTags().contains(this.basicAuthTag)) {
            final String[] userPass = this.basicAuthCredentials.split(":");
            this.stubFor = (MappingBuilder mappingBuilder) -> this.mockSchemaRegistry.stubFor(
                    mappingBuilder.withBasicAuth(userPass[0], userPass[1]));
        } else {
            this.stubFor = (MappingBuilder mappingBuilder) -> this.mockSchemaRegistry.stubFor(mappingBuilder);
        }

        this.mockSchemaRegistry.start();
        this.stubFor.apply(WireMock.get(WireMock.urlPathMatching(SCHEMA_REGISTRATION_PATTERN))
                .willReturn(WireMock.aResponse().withTransformers(this.listVersionsHandler.getName())));
        this.stubFor.apply(WireMock.post(WireMock.urlPathMatching(SCHEMA_REGISTRATION_PATTERN))
                .willReturn(WireMock.aResponse().withTransformers(this.autoRegistrationHandler.getName())));
        this.stubFor.apply(WireMock.get(WireMock.urlPathMatching(SCHEMA_REGISTRATION_PATTERN + "/(?:latest|\\d+)"))
                .willReturn(WireMock.aResponse().withTransformers(this.getVersionHandler.getName())));
        this.stubFor.apply(WireMock.get(WireMock.urlPathMatching(CONFIG_PATTERN))
                .willReturn(WireMock.aResponse().withTransformers(this.getConfigHandler.getName())));
        this.stubFor.apply(WireMock.get(WireMock.urlPathMatching(SCHEMA_BY_ID_PATTERN + "\\d+"))
                .willReturn(WireMock.aResponse().withStatus(HTTP_NOT_FOUND)));
    }

    public int registerSchema(final String topic, boolean isKey, final Schema schema) {
        return this.registerSchema(topic, isKey, schema, new TopicNameStrategy());
    }

    public int registerSchema(final String topic, boolean isKey, final Schema schema, SubjectNameStrategy<Schema> strategy) {
        return this.register(strategy.subjectName(topic, isKey, schema), schema);
    }

    private int register(final String subject, final Schema schema) {
        try {
            final int id = this.schemaRegistryClient.register(subject, schema);
            this.stubFor.apply(WireMock.get(WireMock.urlEqualTo(SCHEMA_BY_ID_PATTERN + id))
                    .willReturn(ResponseDefinitionBuilder.okForJson(new SchemaString(schema.toString()))));
            log.debug("Registered schema {}", id);
            return id;
        } catch (final IOException | RestClientException e) {
            throw new IllegalStateException("Internal error in mock schema registry client", e);
        }
    }

    private List<Integer> listVersions(String subject) {
        log.debug("Listing all versions for subject {}", subject);
        try {
            return this.schemaRegistryClient.getAllVersions(subject);
        } catch (IOException | RestClientException e) {
            throw new IllegalStateException("Internal error in mock schema registry client", e);
        }
    }

    private SchemaMetadata getSubjectVersion(String subject, Object version) {
        log.debug("Requesting version {} for subject {}", version, subject);
        try {
            if (version instanceof String && version.equals("latest")) {
                return this.schemaRegistryClient.getLatestSchemaMetadata(subject);
            } else if (version instanceof Number) {
                return this.schemaRegistryClient.getSchemaMetadata(subject, ((Number) version).intValue());
            } else {
                throw new IllegalArgumentException("Only 'latest' or integer versions are allowed");
            }
        } catch (IOException | RestClientException e) {
            throw new IllegalStateException("Internal error in mock schema registry client", e);
        }
    }

    private String getCompatibility(String subject) {
        if (subject == null) {
            log.debug("Requesting registry base compatibility");
        } else {
            log.debug("Requesting compatibility for subject {}", subject);
        }
        try {
            return this.schemaRegistryClient.getCompatibility(subject);
        } catch (IOException | RestClientException e) {
            throw new IllegalStateException("Internal error in mock schema registry client", e);
        }
    }

    public SchemaRegistryClient getSchemaRegistryClient() {
        return new CachedSchemaRegistryClient(this.getUrl(), IDENTITY_MAP_CAPACITY);
    }

    public String getUrl() {
        return "http://localhost:" + this.mockSchemaRegistry.port();
    }

    private abstract class SubjectsVersioHandler extends ResponseDefinitionTransformer {
        // Expected url pattern /subjects/.*-value/versions
        protected final Splitter urlSplitter = Splitter.on('/').omitEmptyStrings();

        protected String getSubject(Request request) {
            return Iterables.get(this.urlSplitter.split(request.getUrl()), 1);
        }

        @Override
        public boolean applyGlobally() {
            return false;
        }
    }

    private class AutoRegistrationHandler extends SubjectsVersioHandler {

        @Override
        public ResponseDefinition transform(final Request request, final ResponseDefinition responseDefinition,
                                            final FileSource files, final Parameters parameters) {
            try {
                final int id = SchemaRegistryMock.this.register(getSubject(request),
                        new Schema.Parser()
                                .parse(RegisterSchemaRequest.fromJson(request.getBodyAsString()).getSchema()));
                final RegisterSchemaResponse registerSchemaResponse = new RegisterSchemaResponse();
                registerSchemaResponse.setId(id);
                return ResponseDefinitionBuilder.jsonResponse(registerSchemaResponse);
            } catch (final IOException e) {
                throw new IllegalArgumentException("Cannot parse schema registration request", e);
            }
        }

        @Override
        public String getName() {
            return AutoRegistrationHandler.class.getSimpleName();
        }
    }

    private class ListVersionsHandler extends SubjectsVersioHandler {

        @Override
        public ResponseDefinition transform(final Request request, final ResponseDefinition responseDefinition,
                                            final FileSource files, final Parameters parameters) {
            final List<Integer> versions = SchemaRegistryMock.this.listVersions(getSubject(request));
            log.debug("Got versions {}", versions);
            return ResponseDefinitionBuilder.jsonResponse(versions);
        }

        @Override
        public String getName() {
            return ListVersionsHandler.class.getSimpleName();
        }
    }

    private class GetVersionHandler extends SubjectsVersioHandler {

        @Override
        public ResponseDefinition transform(final Request request, final ResponseDefinition responseDefinition,
                                            final FileSource files, final Parameters parameters) {
            String versionStr = Iterables.get(this.urlSplitter.split(request.getUrl()), 3);
            SchemaMetadata metadata;
            if (versionStr.equals("latest")) {
                metadata = SchemaRegistryMock.this.getSubjectVersion(getSubject(request), versionStr);
            } else {
                int version = Integer.parseInt(versionStr);
                metadata = SchemaRegistryMock.this.getSubjectVersion(getSubject(request), version);
            }
            return ResponseDefinitionBuilder.jsonResponse(metadata);
        }

        @Override
        public String getName() {
            return GetVersionHandler.class.getSimpleName();
        }
    }

    private class GetConfigHandler extends SubjectsVersioHandler {

        @Override
        protected String getSubject(Request request) {
            List<String> parts =
                    StreamSupport.stream(this.urlSplitter.split(request.getUrl()).spliterator(), false)
                            .collect(Collectors.toList());

            // return null when this is just /config
            return parts.size() < 2 ? null : parts.get(1);
        }

        @Override
        public ResponseDefinition transform(final Request request, final ResponseDefinition responseDefinition,
                                            final FileSource files, final Parameters parameters) {
            Config config = new Config(SchemaRegistryMock.this.getCompatibility(getSubject(request)));
            return ResponseDefinitionBuilder.jsonResponse(config);
        }

        @Override
        public String getName() {
            return GetConfigHandler.class.getSimpleName();
        }
    }

}