/*
 * The MIT License
 *
 * Copyright (c) 2019 bakdata GmbH
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */
package com.bakdata.schemaregistrymock;

import static java.net.HttpURLConnection.HTTP_NOT_FOUND;

import com.github.tomakehurst.wiremock.WireMockServer;
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.matching.UrlPathPattern;
import com.github.tomakehurst.wiremock.matching.UrlPattern;
import com.google.common.base.Splitter;
import com.google.common.collect.Iterables;
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.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 java.io.IOException;
import java.util.Collection;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.apache.avro.Schema;

/**
 * <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</li>
 * <li>retrieve a schema by id.</li>
 * <li>list and get schema versions of a subject</li>
 * </ul>
 *
 * <p>If you use the TestTopology 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 {
 *     private final SchemaRegistryMock schemaRegistry = new SchemaRegistryMock();
 *
 *     {@literal @BeforeEach}
 *     void setup() {
 *         schemaRegistry.start();
 *     }
 *
 *     {@literal @AfterEach}
 *     void teardown() {
 *         schemaRegistry.stop();
 *     }
 *
 *     {@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);
 *     }
 * }</code></pre>
 *
 * To retrieve the url of the schema registry for a Kafka Streams config, please use {@link #getUrl()}
 */
@Slf4j
public class SchemaRegistryMock {
    private static final String ALL_SUBJECT_PATTERN = "/subjects";
    private static final String SCHEMA_PATH_PATTERN = "/subjects/[^/]+/versions";
    private static final String SCHEMA_BY_ID_PATTERN = "/schemas/ids/";
    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 DeleteSubjectHandler deleteSubjectHandler = new DeleteSubjectHandler();
    private final AllSubjectsHandler allSubjectsHandler = new AllSubjectsHandler();
    private final WireMockServer mockSchemaRegistry = new WireMockServer(
            WireMockConfiguration.wireMockConfig().dynamicPort()
                    .extensions(this.autoRegistrationHandler, this.listVersionsHandler, this.getVersionHandler,
                            this.deleteSubjectHandler, this.allSubjectsHandler));
    private final SchemaRegistryClient schemaRegistryClient = new MockSchemaRegistryClient();

    private static UrlPattern getSchemaPattern(final Integer id) {
        return WireMock.urlPathEqualTo(SCHEMA_BY_ID_PATTERN + id);
    }

    private static UrlPattern getSubjectPattern(final String subject) {
        return WireMock.urlEqualTo(ALL_SUBJECT_PATTERN + "/" + subject);
    }

    private static UrlPattern getSubjectVersionsPattern(final String subject) {
        return WireMock.urlEqualTo(ALL_SUBJECT_PATTERN + "/" + subject + "/versions");
    }

    private static UrlPathPattern getSubjectVersionPattern(final String subject) {
        return WireMock.urlPathMatching(ALL_SUBJECT_PATTERN + "/" + subject + "/versions/(?:latest|\\d+)");
    }

    public void start() {
        this.mockSchemaRegistry.start();
        this.mockSchemaRegistry.stubFor(WireMock.get(WireMock.urlPathMatching(SCHEMA_PATH_PATTERN))
                .willReturn(WireMock.aResponse().withStatus(HTTP_NOT_FOUND)));
        this.mockSchemaRegistry.stubFor(WireMock.post(WireMock.urlPathMatching(SCHEMA_PATH_PATTERN))
                .willReturn(WireMock.aResponse().withTransformers(this.autoRegistrationHandler.getName())));
        this.mockSchemaRegistry.stubFor(WireMock.get(WireMock.urlPathMatching(SCHEMA_PATH_PATTERN + "/(?:latest|\\d+)"))
                .willReturn(WireMock.aResponse().withStatus(HTTP_NOT_FOUND)));
        this.mockSchemaRegistry.stubFor(WireMock.get(WireMock.urlPathMatching(SCHEMA_BY_ID_PATTERN + "\\d+"))
                .willReturn(WireMock.aResponse().withStatus(HTTP_NOT_FOUND)));
        this.mockSchemaRegistry.stubFor(WireMock.delete(WireMock.urlPathMatching(ALL_SUBJECT_PATTERN + "/[^/]+"))
                .willReturn(WireMock.aResponse().withStatus(HTTP_NOT_FOUND)));
        this.mockSchemaRegistry.stubFor(WireMock.get(WireMock.urlPathMatching(ALL_SUBJECT_PATTERN))
                .willReturn(WireMock.aResponse().withTransformers(this.allSubjectsHandler.getName())));
    }

    public void stop() {
        this.mockSchemaRegistry.stop();
    }

    public int registerKeySchema(final String topic, final Schema schema) {
        return this.register(topic + "-key", schema);
    }

    public int registerValueSchema(final String topic, final Schema schema) {
        return this.register(topic + "-value", schema);
    }

    public List<Integer> deleteKeySchema(final String subject) {
        return this.delete(subject + "-key");
    }

    public List<Integer> deleteValueSchema(final String subject) {
        return this.delete(subject + "-value");
    }

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

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

    private int register(final String subject, final Schema schema) {
        try {
            final int id = this.schemaRegistryClient.register(subject, schema);
            this.mockSchemaRegistry.stubFor(WireMock.get(getSchemaPattern(id))
                    .withQueryParam("fetchMaxId", WireMock.matching("false|true"))
                    .willReturn(ResponseDefinitionBuilder.okForJson(new SchemaString(schema.toString()))));
            this.mockSchemaRegistry.stubFor(WireMock.delete(getSubjectPattern(subject))
                    .willReturn(WireMock.aResponse().withTransformers(this.deleteSubjectHandler.getName())));
            this.mockSchemaRegistry.stubFor(WireMock.get(getSubjectVersionsPattern(subject))
                    .willReturn(WireMock.aResponse().withTransformers(this.listVersionsHandler.getName())));
            this.mockSchemaRegistry.stubFor(WireMock.get(getSubjectVersionPattern(subject))
                    .willReturn(WireMock.aResponse().withTransformers(this.getVersionHandler.getName())));
            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> delete(final String subject) {
        try {
            final List<Integer> ids = this.schemaRegistryClient.getAllVersions(subject);
            ids.forEach(id -> this.mockSchemaRegistry.removeStub(WireMock.get(getSchemaPattern(id))));
            this.mockSchemaRegistry.removeStub(WireMock.delete(getSubjectPattern(subject)));
            this.mockSchemaRegistry.removeStub(WireMock.get(getSubjectVersionsPattern(subject)));
            this.mockSchemaRegistry.removeStub(WireMock.get(getSubjectVersionPattern(subject)));
            this.schemaRegistryClient.deleteSubject(subject);
            return ids;
        } catch (final IOException | RestClientException e) {
            throw new IllegalStateException("Internal error in mock schema registry client", e);
        }
    }

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

    private SchemaMetadata getSubjectVersion(final String subject, final 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 (final IOException | RestClientException e) {
            throw new IllegalStateException("Internal error in mock schema registry client", e);
        }
    }

    private Collection<String> listAllSubjects() {
        try {
            return this.schemaRegistryClient.getAllSubjects();
        } catch (final IOException | RestClientException e) {
            throw new IllegalStateException("Internal error in mock schema registry client", e);
        }
    }

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

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

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

    private class AutoRegistrationHandler extends SubjectsHandler {

        @Override
        public ResponseDefinition transform(final Request request, final ResponseDefinition responseDefinition,
                final FileSource files, final Parameters parameters) {
            final String subject = Iterables.get(this.urlSplitter.split(request.getUrl()), 1);
            try {
                final int id = SchemaRegistryMock.this.register(subject,
                        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 SubjectsHandler {

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

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

    private class GetVersionHandler extends SubjectsHandler {

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

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

    private class DeleteSubjectHandler extends SubjectsHandler {
        @Override
        public ResponseDefinition transform(final Request request, final ResponseDefinition responseDefinition,
                final FileSource files, final Parameters parameters) {
            final List<Integer> ids = SchemaRegistryMock.this.delete(this.getSubject(request));
            return ResponseDefinitionBuilder.jsonResponse(ids);
        }

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

    private class AllSubjectsHandler extends SubjectsHandler {
        @Override
        public ResponseDefinition transform(final Request request, final ResponseDefinition responseDefinition,
                final FileSource files, final Parameters parameters) {
            final Collection<String> body = SchemaRegistryMock.this.listAllSubjects();
            return ResponseDefinitionBuilder.jsonResponse(body);
        }

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