/*******************************************************************************
 * Copyright (c) 2016, 2020 Contributors to the Eclipse Foundation
 *
 * See the NOTICE file(s) distributed with this work for additional
 * information regarding copyright ownership.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0
 *
 * SPDX-License-Identifier: EPL-2.0
 *******************************************************************************/

package org.eclipse.hono.tests;

import java.net.HttpURLConnection;
import java.nio.charset.StandardCharsets;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

import javax.security.auth.x500.X500Principal;

import org.eclipse.hono.service.http.HttpUtils;
import org.eclipse.hono.service.management.credentials.CommonCredential;
import org.eclipse.hono.service.management.credentials.PasswordCredential;
import org.eclipse.hono.service.management.credentials.PskCredential;
import org.eclipse.hono.service.management.credentials.PskSecret;
import org.eclipse.hono.service.management.credentials.X509CertificateCredential;
import org.eclipse.hono.service.management.credentials.X509CertificateSecret;
import org.eclipse.hono.service.management.device.Device;
import org.eclipse.hono.service.management.tenant.Tenant;
import org.eclipse.hono.util.RegistryManagementConstants;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.net.UrlEscapers;

import io.vertx.core.Future;
import io.vertx.core.MultiMap;
import io.vertx.core.Vertx;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.http.HttpHeaders;
import io.vertx.core.json.Json;
import io.vertx.core.json.JsonObject;

/**
 * A client for accessing the Device Registry's HTTP resources for the Device Registration, Credentials and Tenant API.
 *
 */
public final class DeviceRegistryHttpClient {

    /**
     * The URI pattern for adding a tenant.
     */
    public static final String URI_ADD_TENANT = String.format("/%s/%s",
            RegistryManagementConstants.API_VERSION, RegistryManagementConstants.TENANT_HTTP_ENDPOINT);
    /**
     * The URI pattern for addressing a tenant instance.
     */
    public static final String TEMPLATE_URI_TENANT_INSTANCE = String.format("/%s/%s/%%s",
            RegistryManagementConstants.API_VERSION, RegistryManagementConstants.TENANT_HTTP_ENDPOINT);

    /**
     * The URI pattern for addressing a device instance.
     */
    public static final String TEMPLATE_URI_REGISTRATION_WITHOUT_ID = String.format("/%s/%s/%%s",
            RegistryManagementConstants.API_VERSION, RegistryManagementConstants.REGISTRATION_HTTP_ENDPOINT);

    /**
     * The URI pattern for addressing adding a device without id.
     */
    public static final String TEMPLATE_URI_REGISTRATION_INSTANCE = String.format("/%s/%s/%%s/%%s",
            RegistryManagementConstants.API_VERSION, RegistryManagementConstants.REGISTRATION_HTTP_ENDPOINT);

    /**
     * The URI pattern for addressing the credentials of a device.
     */
    public static final String TEMPLATE_URI_CREDENTIALS_BY_DEVICE = String.format("/%s/%s/%%s/%%s",
            RegistryManagementConstants.API_VERSION, RegistryManagementConstants.CREDENTIALS_HTTP_ENDPOINT);
    /**
     * The URI pattern for addressing a device's credentials of a specific type.
     */
    public static final String TEMPLATE_URI_CREDENTIALS_INSTANCE = String.format("/%s/%s/%%s/%%s/%%s",
            RegistryManagementConstants.API_VERSION, RegistryManagementConstants.CREDENTIALS_HTTP_ENDPOINT);

    private static final Logger LOG = LoggerFactory.getLogger(DeviceRegistryHttpClient.class);

    private static final String CONTENT_TYPE_APPLICATION_JSON = "application/json";
    private final CrudHttpClient httpClient;

    /**
     * Creates a new client for a host and port.
     *
     * @param vertx The vert.x instance to use.
     * @param host The host to invoke the operations on.
     * @param port The port that the service is bound to.
     * @throws NullPointerException if any of the parameters is {@code null}.
     */
    public DeviceRegistryHttpClient(final Vertx vertx, final String host, final int port) {
        this.httpClient = new CrudHttpClient(vertx, host, port);
    }

    private static String credentialsByDeviceUri(final String tenant, final String deviceId) {
        return String.format(
                TEMPLATE_URI_CREDENTIALS_BY_DEVICE,
                Optional.ofNullable(tenant)
                    .map(t -> UrlEscapers.urlPathSegmentEscaper().escape(t))
                    .orElse(""),
                Optional.ofNullable(deviceId)
                    .map(d -> UrlEscapers.urlPathSegmentEscaper().escape(d))
                    .orElse(""));
    }

    private static String credentialsInstanceUri(final String tenant, final String authId, final String type) {
        return String.format(
                TEMPLATE_URI_CREDENTIALS_INSTANCE,
                Optional.ofNullable(tenant)
                    .map(t -> UrlEscapers.urlPathSegmentEscaper().escape(t))
                    .orElse(""),
                Optional.ofNullable(authId)
                    .map(a -> UrlEscapers.urlPathSegmentEscaper().escape(a))
                    .orElse(""),
                Optional.ofNullable(type)
                    .map(t -> UrlEscapers.urlPathSegmentEscaper().escape(t))
                    .orElse(""));
    }

    private static String tenantInstanceUri(final String tenant) {
        return String.format(
                TEMPLATE_URI_TENANT_INSTANCE,
                Optional.ofNullable(tenant)
                    .map(t -> UrlEscapers.urlPathSegmentEscaper().escape(t))
                    .orElse(""));
    }

    private static String registrationInstanceUri(final String tenant, final String deviceId) {
        return String.format(
                TEMPLATE_URI_REGISTRATION_INSTANCE,
                Optional.ofNullable(tenant)
                    .map(t -> UrlEscapers.urlPathSegmentEscaper().escape(t))
                    .orElse(""),
                Optional.ofNullable(deviceId)
                    .map(d -> UrlEscapers.urlPathSegmentEscaper().escape(d))
                    .orElse(""));
    }

    private static String registrationWithoutIdUri(final String tenant) {
        return String.format(
                TEMPLATE_URI_REGISTRATION_WITHOUT_ID,
                Optional.ofNullable(tenant)
                    .map(t -> UrlEscapers.urlPathSegmentEscaper().escape(t))
                    .orElse(""));
    }

    // tenant management

    /**
     * Adds configuration information for a tenant without an ID nor payload.
     * <p>
     * The tenant identifier will be generated by the service implementation.
     * This method simply invokes {@link #addTenant(String, Tenant, String, int)} with
     * {@link HttpURLConnection#HTTP_CREATED} as the expected status code.
     *
     * @return A future indicating the outcome of the operation. The future will succeed if the tenant has been created
     *         successfully. Otherwise the future will fail with a {@link ServiceInvocationException}.
     */
    public Future<MultiMap> addTenant() {
        return addTenant(null, (Tenant) null, null, HttpURLConnection.HTTP_CREATED);
    }

    /**
     * Adds configuration information for a tenant without a payload.
     * <p>
     * This method simply invokes {@link #addTenant(String, Tenant, String, int)} with
     * {@link HttpURLConnection#HTTP_CREATED} as the expected status code.
     *
     * @param tenantId The id of the tenant to add.
     * @return A future indicating the outcome of the operation. The future will succeed if the tenant has been created
     *         successfully. Otherwise the future will fail with a {@link ServiceInvocationException}.
     */
    public Future<MultiMap> addTenant(final String tenantId) {
        return addTenant(tenantId, (Tenant) null, null, HttpURLConnection.HTTP_CREATED);
    }

    /**
     * Adds configuration information for a tenant.
     * <p>
     * This method simply invokes {@link #addTenant(String, Tenant, int)} with
     * {@link HttpURLConnection#HTTP_CREATED} as the expected status code.
     *
     * @param tenantId The id of the tenant to add.
     * @param requestPayload The request payload as specified by the Tenant management API.
     * @return A future indicating the outcome of the operation. The future will succeed if the tenant has been created
     *         successfully. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     */
    public Future<MultiMap> addTenant(final String tenantId, final Tenant requestPayload) {
        return addTenant(tenantId, requestPayload, HttpURLConnection.HTTP_CREATED);
    }

    /**
     * Adds configuration information for a tenant.
     * <p>
     * This method simply invokes {@link #addTenant(String, Tenant, String, int)} with <em>application/json</em> as
     * content type and {@link HttpURLConnection#HTTP_CREATED} as the expected status code.
     *
     * @param tenantId The id of the tenant to add.
     * @param requestPayload The request payload as specified by the Tenant management API.
     * @param expectedStatusCode The status code indicating a successful outcome.
     * @return A future indicating the outcome of the operation. The future will succeed if the response contained the
     *         expected status code. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     */
    public Future<MultiMap> addTenant(final String tenantId, final Tenant requestPayload,
            final int expectedStatusCode) {
        return addTenant(tenantId, requestPayload, CONTENT_TYPE_APPLICATION_JSON, expectedStatusCode);
    }

    /**
     * Adds configuration information for a tenant.
     *
     * @param tenantId The id of the tenant to add.
     * @param requestPayload The request payload as specified by the Tenant management API.
     * @param contentType The content type to set in the request.
     * @param expectedStatusCode The status code indicating a successful outcome.
     * @return A future indicating the outcome of the operation. The future will succeed if the response contained the
     *         expected status code. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     */
    public Future<MultiMap> addTenant(final String tenantId, final Tenant requestPayload, final String contentType,
            final int expectedStatusCode) {

        return addTenant(
                tenantId,
                JsonObject.mapFrom(requestPayload),
                contentType,
                expectedStatusCode);
    }

    /**
     * Adds configuration information for a tenant.
     *
     * @param tenantId The id of the tenant to add.
     * @param requestPayload The request payload as specified by the Tenant management API.
     * @param contentType The content type to set in the request.
     * @param expectedStatusCode The status code indicating a successful outcome.
     * @return A future indicating the outcome of the operation. The future will succeed if the response contained the
     *         expected status code. Otherwise the future will fail with a {@link ServiceInvocationException}.
     */
    public Future<MultiMap> addTenant(final String tenantId, final JsonObject requestPayload, final String contentType,
            final int expectedStatusCode) {

        final String uri = tenantInstanceUri(tenantId);
        return httpClient.create(uri, requestPayload, contentType,
                response -> response.statusCode() == expectedStatusCode, true);
    }

    /**
     * Gets configuration information for a tenant.
     * <p>
     * This method simply invokes {@link #getTenant(String, int)} with {@link HttpURLConnection#HTTP_OK} as the expected
     * status code.
     *
     * @param tenantId The tenant to get information for.
     * @return A future indicating the outcome of the operation. The future will contain the response payload if the
     *         request succeeded. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     */
    public Future<Buffer> getTenant(final String tenantId) {
        return getTenant(tenantId, HttpURLConnection.HTTP_OK);
    }

    /**
     * Gets configuration information for a tenant.
     *
     * @param tenantId The tenant to get information for.
     * @param expectedStatusCode The status code indicating a successful outcome.
     * @return A future indicating the outcome of the operation. The future will contain the response payload if the
     *         response contained the expected status code. Otherwise the future will fail with a
     *         {@link org.eclipse.hono.client.ServiceInvocationException}.
     */
    public Future<Buffer> getTenant(final String tenantId, final int expectedStatusCode) {

        final String uri = tenantInstanceUri(tenantId);
        return httpClient.get(uri, status -> status == expectedStatusCode);
    }

    /**
     * Updates configuration information for a tenant.
     *
     * @param tenantId The tenant to update information for.
     * @param requestPayload The payload to set, as specified by the Tenant management API.
     * @param expectedStatusCode The status code indicating a successful outcome.
     * @return A future indicating the outcome of the operation. The future will succeed if the response contained the
     *         expected status code. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     */
    public Future<MultiMap> updateTenant(final String tenantId, final Tenant requestPayload,
            final int expectedStatusCode) {

        final String uri = tenantInstanceUri(tenantId);
        final JsonObject payload = JsonObject.mapFrom(requestPayload);
        return httpClient.update(uri, payload, status -> status == expectedStatusCode);
    }

    /**
     * Removes configuration information for a tenant.
     * <p>
     * This method simply invokes {@link #removeTenant(String, int)} with {@link HttpURLConnection#HTTP_NO_CONTENT} as
     * the expected status code.
     *
     * @param tenantId The tenant to remove.
     * @return A future indicating the outcome of the operation. The future will succeed if the tenant has been removed.
     *         Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     */
    public Future<Void> removeTenant(final String tenantId) {

        return removeTenant(tenantId, HttpURLConnection.HTTP_NO_CONTENT);
    }

    /**
     * Removes configuration information for a tenant.
     *
     * @param tenantId The tenant to remove.
     * @param expectedStatusCode The status code indicating a successful outcome.
     * @return A future indicating the outcome of the operation. The future will succeed if the response contained the
     *         expected status code. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     */
    public Future<Void> removeTenant(final String tenantId, final int expectedStatusCode) {

        final String uri = tenantInstanceUri(tenantId);
        return httpClient.delete(uri, status -> status == expectedStatusCode);
    }

    // device registration

    /**
     * Adds registration information for a device.
     * <p>
     * The device identifier will be generated by the service implementation and the device will be enabled by default.
     * <p>
     * This method simply invokes {@link #registerDevice(String, String, Device)} with an empty JSON object as
     * additional data.
     *
     * @param tenantId The tenant that the device belongs to.
     * @return A future indicating the outcome of the operation. The future will succeed if the registration information
     *         has been added successfully. Otherwise the future will fail with a {@link ServiceInvocationException}.
     * @throws NullPointerException if the tenant is {@code null}.
     */
    public Future<MultiMap> registerDevice(final String tenantId) {
        return registerDevice(tenantId, null, new Device());
    }

    /**
     * Adds registration information for a device.
     * <p>
     * The device will be enabled by default.
     * <p>
     * This method simply invokes {@link #registerDevice(String, String, Device)} with an empty JSON object as
     * additional data.
     *
     * @param tenantId The tenant that the device belongs to.
     * @param deviceId The identifier of the device.
     * @return A future indicating the outcome of the operation. The future will succeed if the registration information
     *         has been added successfully. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     * @throws NullPointerException if the tenant is {@code null}.
     */
    public Future<MultiMap> registerDevice(final String tenantId, final String deviceId) {
        return registerDevice(tenantId, deviceId, new Device());
    }

    /**
     * Adds registration information for a device.
     * <p>
     * The device will be enabled by default if not specified otherwise in the additional data.
     * <p>
     * This method simply invokes {@link #registerDevice(String, String, Device, int)} with
     * {@link HttpURLConnection#HTTP_CREATED} as the expected status code.
     *
     * @param tenantId The tenant that the device belongs to.
     * @param deviceId The identifier of the device.
     * @param device Additional properties to register with the device.
     * @return A future indicating the outcome of the operation. The future will succeed if the registration information
     *         has been added successfully. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     * @throws NullPointerException if the tenant is {@code null}.
     */
    public Future<MultiMap> registerDevice(final String tenantId, final String deviceId, final Device device) {
        return registerDevice(tenantId, deviceId, device, HttpURLConnection.HTTP_CREATED);
    }

    /**
     * Adds registration information for a device.
     * <p>
     * The device will be enabled by default if not specified otherwise in the additional data.
     * <p>
     * This method simply invokes {@link #registerDevice(String, String, Device, String, int)} with
     * <em>application/json</em> as the content type.
     *
     * @param tenantId The tenant that the device belongs to.
     * @param deviceId The identifier of the device.
     * @param data Additional properties to register with the device.
     * @param expectedStatus The status code indicating a successful outcome.
     * @return A future indicating the outcome of the operation. The future will succeed if the response contained the
     *         expected status code. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     * @throws NullPointerException if the tenant is {@code null}.
     */
    public Future<MultiMap> registerDevice(final String tenantId, final String deviceId, final Device data,
            final int expectedStatus) {
        return registerDevice(tenantId, deviceId, data, CONTENT_TYPE_APPLICATION_JSON, expectedStatus);
    }

    /**
     * Adds registration information for a device.
     * <p>
     * The device will be enabled by default if not specified otherwise in the additional data.
     *
     * @param tenantId The tenant that the device belongs to.
     * @param deviceId The identifier of the device.
     * @param device Additional properties to register with the device.
     * @param contentType The content type to set on the request.
     * @param expectedStatus The status code indicating a successful outcome.
     * @return A future indicating the outcome of the operation. The future will succeed if the response contained the
     *         expected status code. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     * @throws NullPointerException if the tenant is {@code null}.
     */
    public Future<MultiMap> registerDevice(
            final String tenantId,
            final String deviceId,
            final Device device,
            final String contentType,
            final int expectedStatus) {

        return registerDevice(tenantId, deviceId, JsonObject.mapFrom(device), contentType, expectedStatus);
    }

    /**
     * Adds registration information for a device.
     * <p>
     * The device will be enabled by default if not specified otherwise in the additional data.
     *
     * @param tenantId The tenant that the device belongs to.
     * @param deviceId The identifier of the device.
     * @param device Additional properties to register with the device.
     * @param contentType The content type to set on the request.
     * @param expectedStatus The status code indicating a successful outcome.
     * @return A future indicating the outcome of the operation. The future will succeed if the response contained the
     *         expected status code. Otherwise the future will fail with a {@link ServiceInvocationException}.
     * @throws NullPointerException if the tenant is {@code null}.
     */
    public Future<MultiMap> registerDevice(
            final String tenantId,
            final String deviceId,
            final JsonObject device,
            final String contentType,
            final int expectedStatus) {

        Objects.requireNonNull(tenantId);
        final String uri = Optional.ofNullable(deviceId)
                .map(id -> registrationInstanceUri(tenantId, id))
                .orElse(registrationWithoutIdUri(tenantId));
        return httpClient.create(uri, device, contentType,
                response -> response.statusCode() == expectedStatus, true);
    }

    /**
     * Updates registration information for a device.
     * <p>
     * This method simply invokes {@link #updateDevice(String, String, JsonObject, String, int)} with
     * <em>application/json</em> as the content type and {@link HttpURLConnection#HTTP_NO_CONTENT} as the expected
     * status code.
     *
     * @param tenantId The tenant that the device belongs to.
     * @param deviceId The identifier of the device.
     * @param data Additional properties to register with the device.
     * @return A future indicating the outcome of the operation. The future will succeed if the registration information
     *         has been updated successfully. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     * @throws NullPointerException if the tenant is {@code null}.
     */
    public Future<MultiMap> updateDevice(final String tenantId, final String deviceId, final JsonObject data) {
        return updateDevice(tenantId, deviceId, data, CONTENT_TYPE_APPLICATION_JSON, HttpURLConnection.HTTP_NO_CONTENT);
    }

    /**
     * Updates registration information for a device.
     *
     * @param tenantId The tenant that the device belongs to.
     * @param deviceId The identifier of the device.
     * @param data Additional properties to register with the device.
     * @param contentType The content type to set on the request.
     * @param expectedStatus The status code indicating a successful outcome.
     * @return A future indicating the outcome of the operation. The future will succeed if the response contained the
     *         expected status code. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     * @throws NullPointerException if the tenant is {@code null}.
     */
    public Future<MultiMap> updateDevice(
            final String tenantId,
            final String deviceId,
            final JsonObject data,
            final String contentType,
            final int expectedStatus) {

        Objects.requireNonNull(tenantId);
        final String requestUri = registrationInstanceUri(tenantId, deviceId);
        return httpClient.update(requestUri, data, contentType, status -> status == expectedStatus);
    }

    /**
     * Gets registration information for a device.
     *
     * @param tenantId The tenant that the device belongs to.
     * @param deviceId The identifier of the device.
     * @return A future indicating the outcome of the operation. The future will contain the response payload if the
     *         request succeeded. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     * @throws NullPointerException if the tenant is {@code null}.
     */
    public Future<Buffer> getRegistrationInfo(final String tenantId, final String deviceId) {

        Objects.requireNonNull(tenantId);
        final String requestUri = registrationInstanceUri(tenantId, deviceId);
        return httpClient.get(requestUri, status -> status == HttpURLConnection.HTTP_OK);
    }

    /**
     * Removes registration information for a device.
     *
     * @param tenantId The tenant that the device belongs to.
     * @param deviceId The identifier of the device.
     * @return A future indicating the outcome of the operation. The future will succeed if the registration information
     *         has been removed. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     * @throws NullPointerException if the tenant is {@code null}.
     */
    public Future<Void> deregisterDevice(final String tenantId, final String deviceId) {

        Objects.requireNonNull(tenantId);
        final String requestUri = registrationInstanceUri(tenantId, deviceId);
        return httpClient.delete(requestUri, status -> status == HttpURLConnection.HTTP_NO_CONTENT);
    }

    // credentials management

    /**
     * Add credentials for a device.
     * <p>
     * This method simply invokes {@link #addCredentials(String, String, Collection, int)} with
     * {@link HttpURLConnection#HTTP_CREATED} as the expected status code.
     *
     * @param tenantId The tenant that the device belongs to.
     * @param deviceId The device credentials belongs to.
     * @param secrets The secrets to add.
     * @return A future indicating the outcome of the operation. The future will succeed if the credentials have been
     *         added successfully. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     * @throws NullPointerException if the tenant is {@code null}.
     */
    public Future<MultiMap> addCredentials(final String tenantId, final String deviceId,
            final Collection<CommonCredential> secrets) {
        return addCredentials(tenantId, deviceId, secrets, HttpURLConnection.HTTP_NO_CONTENT);
    }

    /**
     * Add credentials for a device.
     * <p>
     * This method simply invokes {@link #addCredentials(String, String, Collection, String, int)} with
     * <em>application/json</em> as the content type and {@link HttpURLConnection#HTTP_CREATED} as the expected status
     * code.
     *
     * @param tenantId The tenant that the device belongs to.
     * @param deviceId The device credentials belongs to.
     * @param secrets The secrets to add.
     * @param expectedStatusCode The status code indicating a successful outcome.
     * @return A future indicating the outcome of the operation. The future will succeed if the response contained the
     *         expected status code. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     * @throws NullPointerException if the tenant is {@code null}.
     */
    public Future<MultiMap> addCredentials(final String tenantId, final String deviceId,
            final Collection<CommonCredential> secrets, final int expectedStatusCode) {
        return addCredentials(tenantId, deviceId, secrets, CONTENT_TYPE_APPLICATION_JSON, expectedStatusCode);
    }

    /**
     * Add credentials for a device.
     *
     * @param tenantId The tenant that the device belongs to.
     * @param deviceId The device credentials belongs to.
     * @param secrets The secrets to add.
     * @param contentType The content type to set on the request.
     * @param expectedStatusCode The status code indicating a successful outcome.
     * @return A future indicating the outcome of the operation. The future will succeed if the response contained the
     *         expected status code. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     * @throws NullPointerException if the tenant is {@code null}.
     */
    public Future<MultiMap> addCredentials(
            final String tenantId,
            final String deviceId,
            final Collection<CommonCredential> secrets,
            final String contentType,
            final int expectedStatusCode) {

        Objects.requireNonNull(tenantId);
        final String uri = credentialsByDeviceUri(tenantId, deviceId);

        return httpClient.get(uri, response -> response == HttpURLConnection.HTTP_OK)
                .compose(body -> {

                    // new list of secrets

                    // get a list, through an array - workaround for vert.x json issue
                    final List<CommonCredential> currentSecrets = new ArrayList<>(
                            Arrays.asList(Json.decodeValue(body, CommonCredential[].class)));
                    currentSecrets.addAll(secrets);

                    // update

                    // encode array, not list - workaround for vert.x json issue
                    final var payload = Json.encodeToBuffer(currentSecrets.toArray(CommonCredential[]::new));
                    return httpClient.update(uri, payload, contentType,
                            response -> response == expectedStatusCode, true);
                });

    }

    /**
     * Gets all credentials registered for a device.
     *
     * @param tenantId The tenant that the device belongs to.
     * @param deviceId The identifier of the device.
     * @return A future indicating the outcome of the operation. The future will contain the response payload if the
     *         request succeeded. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     * @throws NullPointerException if the tenant is {@code null}.
     */
    public Future<Buffer> getCredentials(final String tenantId, final String deviceId) {

        Objects.requireNonNull(tenantId);
        final String uri = credentialsByDeviceUri(tenantId, deviceId);
        return httpClient.get(uri, status -> status == HttpURLConnection.HTTP_OK);
    }

    /**
     * Gets credentials of a specific type for a device.
     *
     * @param tenantId The tenant that the device belongs to.
     * @param authId The authentication identifier of the device.
     * @param type The type of credentials to retrieve.
     * @param expectedStatusCode The status code indicating a successful outcome.
     * @return A future indicating the outcome of the operation. The future will contain the response payload if the
     *         response contains the expected status code. Otherwise the future will fail with a
     *         {@link org.eclipse.hono.client.ServiceInvocationException}.
     * @throws NullPointerException if the tenant is {@code null}.
     */
    public Future<Buffer> getCredentials(final String tenantId, final String authId, final String type,
            final int expectedStatusCode) {

        Objects.requireNonNull(tenantId);
        final String uri = credentialsInstanceUri(tenantId, authId, type);
        return httpClient.get(uri, status -> status == expectedStatusCode);
    }

    /**
     * Updates credentials of a specific type for a device.
     * <p>
     * This method simply invokes {@link #updateCredentials(String, String, Collection, int)} with
     * {@link HttpURLConnection#HTTP_NO_CONTENT} as the expected status code.
     *
     * @param tenantId The tenant that the device belongs to.
     * @param deviceId The identifier of the device.
     * @param credentialsSpec The JSON object to be sent in the request body.
     * @return A future indicating the outcome of the operation. The future will succeed if the credentials have been
     *         updated successfully. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     * @throws NullPointerException if the tenant is {@code null}.
     */
    public Future<MultiMap> updateCredentials(final String tenantId, final String deviceId,
            final CommonCredential credentialsSpec) {
        return updateCredentials(tenantId, deviceId, Collections.singleton(credentialsSpec),
                HttpURLConnection.HTTP_NO_CONTENT);
    }

    /**
     * Updates credentials of a specific type for a device.
     *
     * @param tenantId The tenant that the device belongs to.
     * @param deviceId The identifier of the device.
     * @param credentialsSpec The JSON array to be sent in the request body.
     * @param version The version of credentials to be sent as request header.
     * @param expectedStatusCode The status code indicating a successful outcome.
     * @return A future indicating the outcome of the operation.
     *         The future will succeed if the response contains the expected status code.
     *         Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     * @throws NullPointerException if the tenant is {@code null}.
     */
    public Future<MultiMap> updateCredentialsWithVersion(
            final String tenantId,
            final String deviceId,
            final Collection<CommonCredential> credentialsSpec,
            final String version,
            final int expectedStatusCode) {

        Objects.requireNonNull(tenantId);
        final String uri = credentialsByDeviceUri(tenantId, deviceId);

        final MultiMap headers = MultiMap.caseInsensitiveMultiMap()
                .add(HttpHeaders.IF_MATCH, version)
                .add(HttpHeaders.CONTENT_TYPE, HttpUtils.CONTENT_TYPE_JSON);

        // encode array not list, workaround for vert.x issue
        final var payload = Json.encodeToBuffer(credentialsSpec.toArray(CommonCredential[]::new));

        return httpClient.update(uri, payload, headers, status -> status == expectedStatusCode, true);
    }

    /**
     * Updates credentials of a specific type for a device.
     *
     * @param tenantId The tenant that the device belongs to.
     * @param deviceId The identifier of the device.
     * @param credentialsSpec The JSON array to be sent in the request body.
     * @param expectedStatusCode The status code indicating a successful outcome.
     * @return A future indicating the outcome of the operation. The future will succeed if the response contains the
     *         expected status code. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     * @throws NullPointerException if the tenant is {@code null}.
     */
    public Future<MultiMap> updateCredentials(
            final String tenantId,
            final String deviceId,
            final Collection<CommonCredential> credentialsSpec,
            final int expectedStatusCode) {

        return updateCredentials(tenantId, deviceId, credentialsSpec, CrudHttpClient.CONTENT_TYPE_JSON,
                expectedStatusCode);
    }

    /**
     * Updates credentials of a specific type for a device.
     *
     * @param tenantId The tenant that the device belongs to.
     * @param deviceId The identifier of the device.
     * @param credentialsSpec The JSON array to be sent in the request body.
     * @param contentType The content type to set on the request.
     * @param expectedStatusCode The status code indicating a successful outcome.
     * @return A future indicating the outcome of the operation. The future will succeed if the response contains the
     *         expected status code. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     * @throws NullPointerException if the tenant is {@code null}.
     */
    public Future<MultiMap> updateCredentials(
            final String tenantId,
            final String deviceId,
            final Collection<CommonCredential> credentialsSpec,
            final String contentType,
            final int expectedStatusCode) {

        Objects.requireNonNull(credentialsSpec);

        // encode array not list, workaround for vert.x issue
        final var payload = Json.encodeToBuffer(credentialsSpec.toArray(CommonCredential[]::new));

        return updateCredentialsRaw(tenantId, deviceId, payload, contentType, expectedStatusCode);

    }

    /**
     * Execute an update credentials request, with raw payload.
     *
     * @param tenantId The tenant that the device belongs to.
     * @param deviceId The identifier of the device.
     * @param payload The raw payload.
     * @param contentType The content type to set on the request.
     * @param expectedStatusCode The status code indicating a successful outcome.
     * @return A future indicating the outcome of the operation. The future will succeed if the response contains the
     *         expected status code. Otherwise the future will fail with a {@link org.eclipse.hono.client.ServiceInvocationException}.
     * @throws NullPointerException if the tenant is {@code null}.
     */
    public Future<MultiMap> updateCredentialsRaw(
            final String tenantId,
            final String deviceId,
            final Buffer payload,
            final String contentType,
            final int expectedStatusCode) {

        Objects.requireNonNull(tenantId);
        final String uri = credentialsByDeviceUri(tenantId, deviceId);

        return httpClient.update(uri, payload, contentType, status -> status == expectedStatusCode, true);
    }

    // convenience methods

    /**
     * Creates a tenant and adds a device to it with a given password.
     * <p>
     * This method simply invokes {@link #addDeviceForTenant(String, Tenant, String, Device, String)} with no extra
     * data.
     *
     * @param tenantId The ID of the tenant to create.
     * @param tenant The tenant payload as specified by the Tenant management API.
     * @param deviceId The identifier of the device to add to the tenant.
     * @param password The password to use for the device's credentials.
     * @return A future indicating the outcome of the operation.
     * @throws NullPointerException if tenant is {@code null}.
     */
    public Future<MultiMap> addDeviceForTenant(final String tenantId, final Tenant tenant, final String deviceId,
            final String password) {

        return addDeviceForTenant(tenantId, tenant, deviceId, new Device(), password);
    }

    /**
     * Creates a tenant and adds a device to it with a given password.
     * <p>
     * The password will be added as a hashed password using the device identifier as the authentication identifier.
     *
     * @param tenantId The ID of the tenant to create.
     * @param tenant The tenant payload as specified by the Tenant management API.
     * @param deviceId The identifier of the device to add.
     * @param device The data to register for the device.
     * @param password The password to use for the device's credentials.
     * @return A future indicating the outcome of the operation.
     * @throws NullPointerException if tenant is {@code null}.
     */
    public Future<MultiMap> addDeviceForTenant(
            final String tenantId,
            final Tenant tenant,
            final String deviceId,
            final Device device,
            final String password) {

        Objects.requireNonNull(tenant);

        final PasswordCredential secret = IntegrationTestSupport.createPasswordCredential(deviceId, password);

        return addTenant(tenantId, tenant)
                .compose(ok -> registerDevice(tenantId, deviceId, device))
                .compose(ok -> addCredentials(tenantId, deviceId,
                        Collections.singleton(secret)));
    }

    /**
     * Adds a device with a given password to an existing tenant.
     * <p>
     * The password will be added as a hashed password using the device identifier as the authentication identifier.
     *
     * @param tenantId The identifier of the tenant to add the device to.
     * @param deviceId The identifier of the device to add.
     * @param password The password to use for the device's credentials.
     * @return A future indicating the outcome of the operation.
     * @throws NullPointerException if any of the parameters are {@code null}.
     */
    public Future<MultiMap> addDeviceToTenant(
            final String tenantId,
            final String deviceId,
            final String password) {

        return addDeviceToTenant(tenantId, deviceId, new Device(), password);
    }

    /**
     * Adds a device with a given password to an existing tenant.
     * <p>
     * The password will be added as a hashed password using the device identifier as the authentication identifier.
     *
     * @param tenantId The identifier of the tenant to add the device to.
     * @param deviceId The identifier of the device to add.
     * @param data The data to register for the device.
     * @param password The password to use for the device's credentials.
     * @return A future indicating the outcome of the operation.
     * @throws NullPointerException if any of the parameters are {@code null}.
     */
    public Future<MultiMap> addDeviceToTenant(
            final String tenantId,
            final String deviceId,
            final Device data,
            final String password) {

        Objects.requireNonNull(tenantId);
        Objects.requireNonNull(deviceId);
        Objects.requireNonNull(data);
        Objects.requireNonNull(password);

        final PasswordCredential secret = IntegrationTestSupport.createPasswordCredential(deviceId, password);

        return registerDevice(tenantId, deviceId, data)
                .compose(ok -> addCredentials(tenantId, deviceId, Collections.singletonList(secret)));
    }

    /**
     * Creates a tenant and adds a device to it with a given client certificate.
     * <p>
     * The device will be registered with a set of <em>x509-cert</em> credentials using the client certificate's subject
     * DN as authentication identifier.
     *
     * @param tenantId The identifier of the tenant to add the secret to.
     * @param tenant The tenant payload as specified by the Tenant management API.
     * @param deviceId The identifier of the device to add to the tenant.
     * @param deviceCert The device's client certificate.
     * @return A future indicating the outcome of the operation.
     * @throws NullPointerException if tenant or certificate are {@code null}.
     */
    public Future<Void> addDeviceForTenant(final String tenantId, final Tenant tenant, final String deviceId,
            final X509Certificate deviceCert) {

        Objects.requireNonNull(tenant);

        return addTenant(tenantId, tenant)
                .compose(ok -> registerDevice(tenantId, deviceId))
                .compose(ok -> {

                    final String authId = deviceCert.getSubjectDN().getName();
                    final X509CertificateCredential credential = new X509CertificateCredential(authId);
                    credential.getSecrets().add(new X509CertificateSecret());

                    return addCredentials(tenantId, deviceId, Collections.singleton(credential));

                }).map(ok -> {
                    LOG.debug("registered device with client certificate [tenant-id: {}, device-id: {}, auth-id: {}]",
                            tenantId, deviceId, deviceCert.getSubjectX500Principal().getName(X500Principal.RFC2253));
                    return null;
                });
    }

    /**
     * Creates a tenant and adds a device to it with a given Pre-Shared Key.
     * <p>
     * The device will be registered with a set of <em>psk</em> credentials using the device identifier as the
     * authentication identifier and PSK identity.
     *
     * @param tenantId The identifier of the tenant to add the secret to.
     * @param tenant The tenant payload as specified by the Tenant management API.
     * @param deviceId The identifier of the device to add to the tenant.
     * @param key The shared key.
     * @return A future indicating the outcome of the operation.
     * @throws NullPointerException if any of the parameters are are {@code null}.
     */
    public Future<MultiMap> addPskDeviceForTenant(final String tenantId, final Tenant tenant, final String deviceId,
            final String key) {
        return addPskDeviceForTenant(tenantId, tenant, deviceId, new Device(), key);
    }

    /**
     * Creates a tenant and adds a device to it with a given Pre-Shared Key.
     * <p>
     * The device will be registered with a set of <em>psk</em> credentials using the device identifier as the
     * authentication identifier and PSK identity.
     *
     * @param tenantId The identifier of the tenant to add the secret to.
     * @param tenant The tenant payload as specified by the Tenant management API.
     * @param deviceId The identifier of the device to add to the tenant.
     * @param deviceData Additional data to register for the device.
     * @param key The shared key.
     * @return A future indicating the outcome of the operation.
     * @throws NullPointerException if any of the parameters are are {@code null}.
     */
    public Future<MultiMap> addPskDeviceForTenant(
            final String tenantId,
            final Tenant tenant,
            final String deviceId,
            final Device deviceData,
            final String key) {

        Objects.requireNonNull(tenant);
        Objects.requireNonNull(deviceId);
        Objects.requireNonNull(deviceData);
        Objects.requireNonNull(key);

        final PskCredential credential = new PskCredential(deviceId);

        final PskSecret secret = new PskSecret();
        secret.setKey(key.getBytes(StandardCharsets.UTF_8));
        credential.getSecrets().add(secret);

        return addTenant(tenantId, tenant)
                .compose(ok -> registerDevice(tenantId, deviceId, deviceData))
                .compose(ok -> addCredentials(tenantId, deviceId, Collections.singleton(credential)));

    }

    /**
     * Adds a device with a given Pre-Shared Key to an existing tenant.
     * <p>
     * The key will be added as a <em>psk</em> secret using the device identifier as the authentication identifier and
     * PSK identity.
     *
     * @param tenantId The identifier of the tenant to add the device to.
     * @param deviceId The identifier of the device to add.
     * @param key The shared key.
     * @return A future indicating the outcome of the operation.
     * @throws NullPointerException if any of the parameters are {@code null}.
     */
    public Future<MultiMap> addPskDeviceToTenant(final String tenantId, final String deviceId, final String key) {

        final PskCredential credential = new PskCredential(deviceId);

        final PskSecret secret = new PskSecret();
        secret.setKey(key.getBytes(StandardCharsets.UTF_8));
        credential.getSecrets().add(secret);

        return registerDevice(tenantId, deviceId)
                .compose(ok -> addCredentials(tenantId, deviceId, Collections.singleton(credential)));
    }

}