package com.dvoiss.sensorannotations;

import com.dvoiss.sensorannotations.exception.ProcessingException;
import com.dvoiss.sensorannotations.internal.ListenerMethod;
import com.google.common.base.Joiner;
import com.squareup.javapoet.ClassName;
import com.squareup.javapoet.CodeBlock;
import com.squareup.javapoet.FieldSpec;
import com.squareup.javapoet.JavaFile;
import com.squareup.javapoet.MethodSpec;
import com.squareup.javapoet.MethodSpec.Builder;
import com.squareup.javapoet.ParameterSpec;
import com.squareup.javapoet.ParameterizedTypeName;
import com.squareup.javapoet.TypeName;
import com.squareup.javapoet.TypeSpec;
import java.io.IOException;
import java.io.Writer;
import java.lang.annotation.Annotation;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import javax.annotation.processing.Filer;
import javax.annotation.processing.ProcessingEnvironment;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.PackageElement;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.VariableElement;
import javax.lang.model.type.TypeMirror;
import javax.lang.model.util.Elements;
import javax.tools.JavaFileObject;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;

import static com.dvoiss.sensorannotations.AnnotatedMethod.INVALID_DELAY;

class SensorAnnotationsFileBuilder {
    /**
     * The suffix will be added to the name of the generated class.
     */
    private static final String SUFFIX = "$$SensorBinder";

    static final int TYPE_SIGNIFICANT_MOTION = 17;

    // region Static Types that are used in the methods below to create types and specs.

    private static final ClassName LISTENER_WRAPPER =
        ClassName.get("com.dvoiss.sensorannotations.internal", "EventListenerWrapper");
    private static final ClassName SENSOR_EVENT_LISTENER_WRAPPER =
        ClassName.get("com.dvoiss.sensorannotations.internal", "SensorEventListenerWrapper");
    private static final ClassName TRIGGER_EVENT_LISTENER_WRAPPER =
        ClassName.get("com.dvoiss.sensorannotations.internal", "TriggerEventListenerWrapper");
    private static final ClassName SENSOR_BINDER =
        ClassName.get("com.dvoiss.sensorannotations.internal", "SensorBinder");

    private static final ClassName SENSOR = ClassName.get("android.hardware", "Sensor");
    private static final ClassName SENSOR_MANAGER =
        ClassName.get("android.hardware", "SensorManager");
    private static final ClassName SENSOR_EVENT = ClassName.get("android.hardware", "SensorEvent");
    private static final ClassName TRIGGER_EVENT =
        ClassName.get("android.hardware", "TriggerEvent");
    private static final ClassName SENSOR_EVENT_LISTENER =
        ClassName.get("android.hardware", "SensorEventListener");
    private static final ClassName TRIGGER_EVENT_LISTENER =
        ClassName.get("android.hardware", "TriggerEventListener");
    private static final ClassName CONTEXT = ClassName.get("android.content", "Context");

    private static final ClassName LIST = ClassName.get("java.util", "List");
    private static final ClassName ARRAY_LIST = ClassName.get("java.util", "ArrayList");

    private static final FieldSpec LISTENER_WRAPPERS_FIELD =
        FieldSpec.builder(ParameterizedTypeName.get(LIST, LISTENER_WRAPPER), "listeners")
            .addModifiers(Modifier.PRIVATE, Modifier.FINAL)
            .build();
    private static final FieldSpec SENSOR_MANAGER_FIELD =
        FieldSpec.builder(SENSOR_MANAGER, "sensorManager")
            .addModifiers(Modifier.PRIVATE, Modifier.FINAL)
            .build();
    private static final MethodSpec UNBIND_METHOD =
        getBaseMethodBuilder("unbind").beginControlFlow("if (this.$N != null)",
            SENSOR_MANAGER_FIELD)
            .beginControlFlow("for ($T wrapper : $N)", LISTENER_WRAPPER, LISTENER_WRAPPERS_FIELD)
            .addStatement("wrapper.unregisterListener($N)", SENSOR_MANAGER_FIELD)
            .endControlFlow()
            .endControlFlow()
            .build();

    // endregion

    /**
     * Generates the code for our "Sensor Binder" class and writes it to the same package as the
     * annotated class.
     *
     * @param groupedMethodsMap Map of annotated methods per class.
     * @param elementUtils ElementUtils class from {@link ProcessingEnvironment}.
     * @param filer File writer class from {@link ProcessingEnvironment}.
     * @throws IOException
     * @throws ProcessingException
     */
    static void generateCode(@NonNull Map<String, AnnotatedMethodsPerClass> groupedMethodsMap,
        @NonNull Elements elementUtils, @NonNull Filer filer)
        throws IOException, ProcessingException {
        for (AnnotatedMethodsPerClass groupedMethods : groupedMethodsMap.values()) {
            // If we've annotated methods in an activity called "ExampleActivity" then that will be
            // the enclosing type element.
            TypeElement enclosingClassTypeElement =
                elementUtils.getTypeElement(groupedMethods.getEnclosingClassName());

            // Create the parameterized type that our generated class will implement,
            // (such as "SensorBinder<ExampleActivity>").
            ParameterizedTypeName parameterizedInterface = ParameterizedTypeName.get(SENSOR_BINDER,
                TypeName.get(enclosingClassTypeElement.asType()));

            // Create the target parameter that will be used in the constructor and bind method,
            // (such as "ExampleActivity").
            ParameterSpec targetParameter =
                ParameterSpec.builder(TypeName.get(enclosingClassTypeElement.asType()), "target")
                    .addModifiers(Modifier.FINAL)
                    .build();

            MethodSpec constructor =
                createConstructor(targetParameter, groupedMethods.getItemsMap());
            MethodSpec bindMethod = createBindMethod(targetParameter, groupedMethods);

            TypeSpec sensorBinderClass =
                TypeSpec.classBuilder(enclosingClassTypeElement.getSimpleName() + SUFFIX)
                    .addModifiers(Modifier.FINAL)
                    .addSuperinterface(parameterizedInterface)
                    .addField(SENSOR_MANAGER_FIELD)
                    .addField(LISTENER_WRAPPERS_FIELD)
                    .addMethod(constructor)
                    .addMethod(bindMethod)
                    .addMethod(UNBIND_METHOD)
                    .build();

            // Output our generated file with the same package as the target class.
            PackageElement packageElement = elementUtils.getPackageOf(enclosingClassTypeElement);
            JavaFileObject jfo =
                filer.createSourceFile(enclosingClassTypeElement.getQualifiedName() + SUFFIX);
            Writer writer = jfo.openWriter();
            JavaFile.builder(packageElement.toString(), sensorBinderClass)
                .addFileComment("This class is generated code from Sensor Lib. Do not modify!")
                .addStaticImport(CONTEXT, "SENSOR_SERVICE")
                .build()
                .writeTo(writer);
            writer.close();
        }
    }

    /**
     * Create the constructor for our generated class.
     *
     * @param targetParameter The target class that has annotated methods.
     * @param itemsMap A map of sensor types found in the annotations with the annotated methods.
     * @return {@link MethodSpec} representing the constructor of our generated class.
     */
    @NonNull
    private static MethodSpec createConstructor(@NonNull ParameterSpec targetParameter,
        @NonNull Map<Integer, Map<Class, AnnotatedMethod>> itemsMap) throws ProcessingException {
        ParameterSpec contextParameter = ParameterSpec.builder(CONTEXT, "context").build();
        Builder constructorBuilder = MethodSpec.constructorBuilder()
            .addModifiers(Modifier.PUBLIC)
            .addParameter(contextParameter)
            .addParameter(targetParameter)
            .addStatement("this.$N = ($T) $N.getSystemService(SENSOR_SERVICE)",
                SENSOR_MANAGER_FIELD, SENSOR_MANAGER, contextParameter)
            .addStatement("this.$N = new $T()", LISTENER_WRAPPERS_FIELD, ARRAY_LIST);

        // Loop through the sensor types that we have annotations for and create the listeners which
        // will call the annotated methods on our target class.
        for (Integer sensorType : itemsMap.keySet()) {
            Map<Class, AnnotatedMethod> annotationMap = itemsMap.get(sensorType);
            AnnotatedMethod sensorChangedAnnotatedMethod = annotationMap.get(OnSensorChanged.class);
            AnnotatedMethod accuracyChangedAnnotatedMethod =
                annotationMap.get(OnAccuracyChanged.class);
            AnnotatedMethod triggerAnnotatedMethod = annotationMap.get(OnTrigger.class);

            if (sensorType == TYPE_SIGNIFICANT_MOTION && (accuracyChangedAnnotatedMethod != null
                || sensorChangedAnnotatedMethod != null)) {
                throw new ProcessingException(null, String.format(
                    "@%s and @%s are not supported for the \"TYPE_SIGNIFICANT_MOTION\" type. Use @%s for this type.",
                    OnSensorChanged.class.getSimpleName(), OnAccuracyChanged.class.getSimpleName(),
                    OnTrigger.class.getSimpleName()));
            } else if (sensorType != TYPE_SIGNIFICANT_MOTION && triggerAnnotatedMethod != null) {
                throw new ProcessingException(null, String.format(
                    "The @%s is only supported for the \"TYPE_SIGNIFICANT_MOTION\" type.",
                    OnTrigger.class.getSimpleName()));
            }

            CodeBlock listenerWrapperCodeBlock;
            if (triggerAnnotatedMethod != null) {
                listenerWrapperCodeBlock = createTriggerListenerWrapper(triggerAnnotatedMethod);
                constructorBuilder.addCode(listenerWrapperCodeBlock);
            } else if (sensorChangedAnnotatedMethod != null
                || accuracyChangedAnnotatedMethod != null) {
                listenerWrapperCodeBlock =
                    createSensorListenerWrapper(sensorType, sensorChangedAnnotatedMethod,
                        accuracyChangedAnnotatedMethod);
                constructorBuilder.addCode(listenerWrapperCodeBlock);
            }
        }

        return constructorBuilder.build();
    }

    /**
     * Create an {@code EventListenerWrapper} that contains the {@code TriggerEventListener} and
     * calls the annotated methods on our target.
     *
     * @param triggerAnnotatedMethod Method annotated with {@link OnTrigger}.
     * @return {@link CodeBlock} of the {@code EventListenerWrapper}.
     */
    @NonNull
    private static CodeBlock createTriggerListenerWrapper(
        @NonNull AnnotatedMethod triggerAnnotatedMethod) throws ProcessingException {
        checkAnnotatedMethodForErrors(triggerAnnotatedMethod.getExecutableElement(),
            OnTrigger.class);

        CodeBlock listenerBlock = CodeBlock.builder()
            .add("new $T() {\n", TRIGGER_EVENT_LISTENER)
            .indent()
            .add(createOnTriggerListenerMethod(triggerAnnotatedMethod).toString())
            .unindent()
            .add("}")
            .build();

        return CodeBlock.builder()
            .addStatement("this.$N.add(new $T($L))", LISTENER_WRAPPERS_FIELD,
                TRIGGER_EVENT_LISTENER_WRAPPER, listenerBlock)
            .build();
    }

    /**
     * Create an {@code EventListenerWrapper} that contains the {@code
     * SensorEventListener} and calls the annotated methods on our target.
     *
     * @param sensorType The {@code Sensor} type.
     * @param sensorChangedAnnotatedMethod Method annotated with {@link OnSensorChanged}.
     * @param accuracyChangedAnnotatedMethod Method annotated with {@link OnAccuracyChanged}.
     * @return {@link CodeBlock} of the {@code EventListenerWrapper}.
     */
    @NonNull
    private static CodeBlock createSensorListenerWrapper(int sensorType,
        @Nullable AnnotatedMethod sensorChangedAnnotatedMethod,
        @Nullable AnnotatedMethod accuracyChangedAnnotatedMethod) throws ProcessingException {
        if (sensorChangedAnnotatedMethod != null) {
            checkAnnotatedMethodForErrors(sensorChangedAnnotatedMethod.getExecutableElement(),
                OnSensorChanged.class);
        }
        if (accuracyChangedAnnotatedMethod != null) {
            checkAnnotatedMethodForErrors(accuracyChangedAnnotatedMethod.getExecutableElement(),
                OnAccuracyChanged.class);
        }

        CodeBlock listenerBlock = CodeBlock.builder()
            .add("new $T() {\n", SENSOR_EVENT_LISTENER)
            .indent()
            .add(createOnSensorChangedListenerMethod(sensorChangedAnnotatedMethod).toString())
            .add(createOnAccuracyChangedListenerMethod(accuracyChangedAnnotatedMethod).toString())
            .unindent()
            .add("}")
            .build();

        int delay =
            getDelayForListener(sensorChangedAnnotatedMethod, accuracyChangedAnnotatedMethod);

        if (delay == INVALID_DELAY) {
            String error =
                String.format("@%s or @%s needs a delay value specified in the annotation",
                    OnSensorChanged.class.getSimpleName(), OnAccuracyChanged.class.getSimpleName());
            throw new ProcessingException(null, error);
        }

        return CodeBlock.builder()
            .addStatement("this.$N.add(new $T($L, $L, $L))", LISTENER_WRAPPERS_FIELD,
                SENSOR_EVENT_LISTENER_WRAPPER, sensorType, delay, listenerBlock)
            .build();
    }

    /**
     * Creates the implementation of {@code TriggerEventListener#onTrigger(TriggerEvent)} which
     * calls the annotated method on our target class.
     *
     * @param annotatedMethod Method annotated with {@code OnTrigger}.
     * @return {@link MethodSpec} of {@code TriggerEventListener#onTrigger(TriggerEvent)}.
     */
    @NonNull
    private static MethodSpec createOnTriggerListenerMethod(
        @NonNull AnnotatedMethod annotatedMethod) {
        ParameterSpec triggerEventParameter = ParameterSpec.builder(TRIGGER_EVENT, "event").build();
        ExecutableElement triggerExecutableElement = annotatedMethod.getExecutableElement();
        return getBaseMethodBuilder("onTrigger").addParameter(triggerEventParameter)
            .addStatement("target.$L($N)", triggerExecutableElement.getSimpleName(),
                triggerEventParameter)
            .build();
    }

    /**
     * Creates the implementation of {@code SensorEventListener#onSensorChanged(SensorEvent)} which
     * calls the annotated method on our target class.
     *
     * @param annotatedMethod Method annotated with {@code OnSensorChanged}.
     * @return {@link MethodSpec} of {@code SensorEventListener#onSensorChanged(SensorEvent)}.
     */
    @NonNull
    private static MethodSpec createOnSensorChangedListenerMethod(
        @Nullable AnnotatedMethod annotatedMethod) {
        ParameterSpec sensorEventParameter = ParameterSpec.builder(SENSOR_EVENT, "event").build();
        Builder methodBuilder =
            getBaseMethodBuilder("onSensorChanged").addParameter(sensorEventParameter);

        if (annotatedMethod != null) {
            ExecutableElement sensorChangedExecutableElement =
                annotatedMethod.getExecutableElement();
            methodBuilder.addStatement("target.$L($N)",
                sensorChangedExecutableElement.getSimpleName(), sensorEventParameter);
        }

        return methodBuilder.build();
    }

    /**
     * Creates the implementation of {@code SensorEventListener#onAccuracyChanged(Sensor, int)}
     * which calls the annotated method on our target class.
     *
     * @param annotatedMethod Method annotated with {@link OnAccuracyChanged}.
     * @return {@link MethodSpec} of {@code SensorEventListener#onAccuracyChanged(Sensor, int)}.
     */
    @NonNull
    private static MethodSpec createOnAccuracyChangedListenerMethod(
        @Nullable AnnotatedMethod annotatedMethod) {
        ParameterSpec sensorParameter = ParameterSpec.builder(SENSOR, "sensor").build();
        ParameterSpec accuracyParameter = ParameterSpec.builder(TypeName.INT, "accuracy").build();
        Builder methodBuilder =
            getBaseMethodBuilder("onAccuracyChanged").addParameter(sensorParameter)
                .addParameter(accuracyParameter);

        if (annotatedMethod != null) {
            ExecutableElement accuracyChangedExecutableElement =
                annotatedMethod.getExecutableElement();
            methodBuilder.addStatement("target.$L($N, $N)",
                accuracyChangedExecutableElement.getSimpleName(), sensorParameter,
                accuracyParameter);
        }

        return methodBuilder.build();
    }

    /**
     * Returns a delay to be used when registering the listener for the sensor. Both {@link
     * OnSensorChanged} and {@link OnAccuracyChanged} can have
     * a delay property set but only one can be used when registering the listener.
     * <p>
     * We try {@link OnSensorChanged} first, then {@link OnAccuracyChanged}, otherwise we return a
     * sentinel value that will be used for errors.
     *
     * @param sensorChangedAnnotatedMethod The method wrapper for the method with the {@link
     * OnSensorChanged} annotation.
     * @param accuracyChangedAnnotatedMethod The method wrapper for the method with the {@link
     * OnAccuracyChanged} annotation.
     * @return A delay value for the sensor listener.
     */
    private static int getDelayForListener(@Nullable AnnotatedMethod sensorChangedAnnotatedMethod,
        @Nullable AnnotatedMethod accuracyChangedAnnotatedMethod) {
        if (sensorChangedAnnotatedMethod != null
            && sensorChangedAnnotatedMethod.getDelay() != INVALID_DELAY) {
            return sensorChangedAnnotatedMethod.getDelay();
        } else if (accuracyChangedAnnotatedMethod != null
            && accuracyChangedAnnotatedMethod.getDelay() != INVALID_DELAY) {
            return accuracyChangedAnnotatedMethod.getDelay();
        }

        return INVALID_DELAY;
    }

    /**
     * Create the bind method for our generated class.
     *
     * @param targetParameter The target class that has annotated methods.
     * @param annotatedMethodsPerClass The annotated methods that are in a given class.
     * @return {@link MethodSpec} of the generated class bind method.
     */
    @NonNull
    private static MethodSpec createBindMethod(@NonNull ParameterSpec targetParameter,
        @NonNull AnnotatedMethodsPerClass annotatedMethodsPerClass) throws ProcessingException {
        Map<Integer, Map<Class, AnnotatedMethod>> itemsMap = annotatedMethodsPerClass.getItemsMap();

        Builder bindMethodBuilder = getBaseMethodBuilder("bind").addParameter(targetParameter)
            .addStatement("int sensorType")
            .addStatement("$T sensor", SENSOR)
            .beginControlFlow("for ($T wrapper : $N)", LISTENER_WRAPPER, LISTENER_WRAPPERS_FIELD)
            .addStatement("sensorType = wrapper.getSensorType()")
            .addStatement("sensor = wrapper.getSensor($N)", SENSOR_MANAGER_FIELD);

        if (annotatedMethodsPerClass.hasAnnotationsOfType(OnSensorNotAvailable.class)) {
            bindMethodBuilder.beginControlFlow("if (sensor == null)");

            // Iterate through our map of sensor types and check whether an OnSensorNotAvailable
            // annotation exists, if so and the sensor is unavailable call the method.
            List<Integer> sensorTypes = new ArrayList<>();
            sensorTypes.addAll(itemsMap.keySet());
            for (int i = 0; i < sensorTypes.size(); i++) {
                Integer sensorType = sensorTypes.get(i);
                Map<Class, AnnotatedMethod> annotationMap = itemsMap.get(sensorType);
                AnnotatedMethod annotatedMethod = annotationMap.get(OnSensorNotAvailable.class);

                if (annotatedMethod != null) {
                    checkAnnotatedMethodForErrors(annotatedMethod.getExecutableElement(),
                        OnSensorNotAvailable.class);

                    if (i == 0) {
                        bindMethodBuilder.beginControlFlow("if (sensorType == $L)", sensorType);
                    } else {
                        bindMethodBuilder.nextControlFlow("else if (sensorType == $L)", sensorType);
                    }

                    bindMethodBuilder.addStatement("$N.$L()", targetParameter,
                        annotatedMethod.getExecutableElement().getSimpleName());
                }
            }

            bindMethodBuilder.endControlFlow().addStatement("continue").endControlFlow();
        }

        return bindMethodBuilder.addStatement("wrapper.registerListener($N)", SENSOR_MANAGER_FIELD)
            .endControlFlow()
            .build();
    }

    /**
     * Return a {@link Builder} with the given method name and default properties.
     *
     * @param name The name of the method.
     * @return A base {@link Builder} to use for methods.
     */
    @NonNull
    private static Builder getBaseMethodBuilder(@NonNull String name) {
        return MethodSpec.methodBuilder(name)
            .addModifiers(Modifier.PUBLIC)
            .returns(void.class)
            .addAnnotation(Override.class);
    }

    /**
     * Check the annotated method for the correct parameters needed based on the annotation.
     *
     * @param element The annotated element.
     * @param annotation The annotation class being checked.
     * @throws ProcessingException
     */
    private static void checkAnnotatedMethodForErrors(@NonNull ExecutableElement element,
        @NonNull Class<? extends Annotation> annotation) throws ProcessingException {
        ListenerMethod method = annotation.getAnnotation(ListenerMethod.class);
        String[] expectedParameters = method.parameters();
        List<? extends VariableElement> parameters = element.getParameters();
        if (parameters.size() != expectedParameters.length) {
            String error = String.format("@%s methods can only have %s parameter(s). (%s.%s)",
                annotation.getSimpleName(), method.parameters().length,
                element.getEnclosingElement().getSimpleName(), element.getSimpleName());
            throw new ProcessingException(element, error);
        }

        for (int i = 0; i < parameters.size(); i++) {
            VariableElement parameter = parameters.get(i);
            TypeMirror methodParameterType = parameter.asType();
            String expectedType = expectedParameters[i];
            if (!expectedType.equals(methodParameterType.toString())) {
                String error = String.format(
                    "Method parameters are not valid for @%s annotated method. Expected parameters of type(s): %s. (%s.%s)",
                    annotation.getSimpleName(), Joiner.on(", ").join(expectedParameters),
                    element.getEnclosingElement().getSimpleName(), element.getSimpleName());
                throw new ProcessingException(element, error);
            }
        }
    }
}