package com.atlassian.bitbucket.mesh;

import com.atlassian.bitbucket.mesh.rpc.v1.RpcGitOptions;
import com.atlassian.bitbucket.mesh.rpc.v1.RpcGitTimeouts;
import com.google.protobuf.Descriptors;
import com.google.protobuf.MessageOrBuilder;
import io.grpc.ManagedChannel;
import org.junit.Test;
import org.junit.runners.model.MultipleFailureException;

import javax.annotation.Resource;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Parameter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;

public abstract class AbstractGrpcServiceTest extends AbstractGrpcTest {

    @Resource
    protected ManagedChannel managedChannel;

    /**
     * Enumerates the {@code public} methods on the service and verifies that the request for each, which will
     * be the first parameter, follows the expected conventions. Those conventions are:
     * <ul>
     *     <li>{@code options} fields must be {@link RpcGitOptions}</li>
     *     <li>{@code repository} fields must be {@link String}s</li>
     *     <li>{@code timeouts} fields must be {@link RpcGitTimeouts}</li>
     * </ul>
     * None of these fields are required to be <i>present</i>, but if they're present they must be of the expected
     * type. This test is capable of producing multiple failures, if there are multiple requests with invalid fields
     * or multiple invalid fields on any request.
     *
     * @throws Exception if inspecting the service class or its methods fails
     */
    @Test
    public void testRequestConventions() throws Exception {
        String testName = getClass().getName();
        int index = testName.lastIndexOf("Service");
        String serviceName = index == -1
                ? testName.substring(0, testName.length() - 4) //Drop Test to get the service name
                : testName.substring(0, index + 7); // Drop everything after Service
        Class<?> serviceClass = Class.forName(serviceName);

        List<Throwable> failures = new ArrayList<>();
        for (Method method : serviceClass.getDeclaredMethods()) {
            Descriptors.Descriptor descriptor = maybeGetRequestDescriptor(method);
            if (descriptor == null) {
                continue;
            }

            failures.addAll(validateFieldType(descriptor, "repository", Descriptors.FieldDescriptor.JavaType.STRING));
            failures.addAll(validateField(descriptor, "options", RpcGitOptions.getDescriptor()));
            failures.addAll(validateField(descriptor, "timeouts", RpcGitTimeouts.getDescriptor()));
        }

        MultipleFailureException.assertEmpty(failures);
    }

    /**
     * Retrieves the descriptor for the method's request, if it appears to be a service method.
     * <p>
     * Service methods are:
     * <ul>
     *     <li>{@code public}</li>
     *     <li>Have at least one parameter</li>
     * </ul>
     * If the method has multiple parameters (for example, server-side streaming methods accept a
     * {@link io.grpc.stub.StreamObserver StreamObserver} as their second parameter), the first is
     * assumed to be the request.
     * <p>
     * The request's descriptor is retrieved by reflectively invoking the Protobuf-generated
     * {@code getDescriptor} static accessor.
     *
     * @param method the method to extract a request descriptor for
     * @return the descriptor for the method's request, or {@code null} if the method does not appear to
     *         be a gRPC service method
     * @throws Exception if inspecting the method fails, or if the descriptor can't be retrieved
     */
    private static Descriptors.Descriptor maybeGetRequestDescriptor(Method method) throws Exception {
        if (Modifier.isPublic(method.getModifiers()) && method.getParameterCount() > 0) {
            Parameter parameter = method.getParameters()[0];

            Class<?> parameterClass = parameter.getType();
            if (MessageOrBuilder.class.isAssignableFrom(parameterClass)) {
                Method getDescriptor = parameterClass.getMethod("getDescriptor");

                return (Descriptors.Descriptor) getDescriptor.invoke(null);
            }
        }

        return null;
    }

    /**
     * If the specified {@code fieldName} is present, validates that it is of the expected type.
     *
     * @param descriptor         the request's descriptor
     * @param fieldName          the field name to validate
     * @param expectedDescriptor the expected descriptor type, if the field is present
     * @return an empty list, if the field is defined according to the expected conventions; otherwise,
     *         a list with one or more validation failures
     */
    private static List<Throwable> validateField(Descriptors.Descriptor descriptor, String fieldName,
                                                 Descriptors.Descriptor expectedDescriptor) {
        Descriptors.FieldDescriptor fieldDescriptor = descriptor.findFieldByName(fieldName);
        if (fieldDescriptor != null) {
            if (fieldDescriptor.getJavaType() == Descriptors.FieldDescriptor.JavaType.MESSAGE) {
                Descriptors.Descriptor actualDescriptor = fieldDescriptor.getMessageType();
                if (!expectedDescriptor.equals(actualDescriptor)) {
                    //The field is a message, as expected, but it's the wrong message
                    return Collections.singletonList(
                            new AssertionError(fieldDescriptor.getFullName() + ": Found a [" +
                                    actualDescriptor.getFullName() + "] where [" +
                                    expectedDescriptor.getFullName() + "] was expected"));
                }
            } else {
                //The field is some other primitive type, not a message. The getType().name().toLowerCase
                //here will produce the same Protobuf type as the definition (e.g. int32 or string)
                return Collections.singletonList(
                        new AssertionError(fieldDescriptor.getFullName() + ": Found a [" +
                                fieldDescriptor.getType().name().toLowerCase(Locale.ROOT) + "] where [" +
                                expectedDescriptor.getFullName() + "] was expected"));
            }
        }

        //Otherwise, if the field isn't present, or is the expected message type, no problem
        return Collections.emptyList();
    }

    /**
     * If the specified {@code fieldName} is present, validates that it is of the expected type.
     *
     * @param descriptor         the request's descriptor
     * @param fieldName          the field name to validate
     * @param expectedType       the expected descriptor type, if the field is present
     * @return an empty list, if the field is defined according to the expected conventions; otherwise,
     *         a list with one or more validation failures
     */
    private static List<Throwable> validateFieldType(Descriptors.Descriptor descriptor, String fieldName,
                                                     Descriptors.FieldDescriptor.JavaType expectedType) {
        Descriptors.FieldDescriptor fieldDescriptor = descriptor.findFieldByName(fieldName);
        if (fieldDescriptor != null) {
            if (fieldDescriptor.getJavaType() != expectedType) {
                //The field is some other type. The getType().name().toLowerCase here will produce the same Protobuf
                //type as the definition (e.g. int32 or string)
                return Collections.singletonList(
                        new AssertionError(fieldDescriptor.getFullName() + ": Found a [" +
                                           fieldDescriptor.getType().name().toLowerCase(Locale.ROOT) + "] where [" +
                                           expectedType.name().toLowerCase(Locale.ROOT) + "] was expected"));
            }
        }

        //Otherwise, if the field isn't present, or is the expected message type, no problem
        return Collections.emptyList();
    }

}
