/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.apache.plc4x.java.opm;

import net.bytebuddy.ByteBuddy;
import net.bytebuddy.description.modifier.Visibility;
import net.bytebuddy.implementation.MethodDelegation;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.plc4x.java.PlcDriverManager;
import org.apache.plc4x.java.api.exceptions.PlcRuntimeException;
import org.apache.plc4x.java.api.messages.PlcReadRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Callable;

import static net.bytebuddy.matcher.ElementMatchers.isDeclaredBy;
import static net.bytebuddy.matcher.ElementMatchers.not;

/**
 * Plc4x equivalent of Jpas EntityManager for implementing Object-Plc-Mapping.
 * This means that calls to a plc can be done by using plain POJOs with Annotations.
 * <p>
 * First, the necessary annotations are {@link PlcEntity} and {@link PlcField}.
 * For a class to be usable as PlcEntity it needs
 * <ul>
 * <li>be non-final (as proxiing has to be used in case of {@link #connect(Class, String)}</li>
 * <li>a public no args constructor for instanciation</li>
 * <li>Needs to be annotated with {@link PlcEntity} and has a valid value which is the connection string</li>
 * </ul>
 * <p>
 * Basically, the {@link PlcEntityManager} has to operation "modes" represented by the methods {@link #read(Class, String)} and
 * {@link #connect(Class, String)}.
 * <p>
 * For a field to get Values from the Plc Injected it needs to be annotated with the {@link PlcField} annotation.
 * The value has to be the plc fields string (which is inserted in the {@link PlcReadRequest}).
 * The connection string is taken from the value of the {@link PlcEntity} annotation on the class.
 * <p>
 * The {@link #read(Class, String)} method has no direkt equivalent in JPA (as far as I know) as it only returns a "detached"
 * entity. This means it fetches all values from the plc that are annotated wiht the {@link PlcField} annotations.
 * <p>
 * The {@link #connect(Class, String)} method is more JPA-like as it returns a "connected" entity. This means, that each
 * time one of the getters on the returned entity is called a call is made to the plc (and the field value is changed
 * for this specific field).
 * Furthermore, if a method which is no getter is called, then all {@link PlcField}s are refreshed before doing the call.
 * Thus, all operations on fields that are annotated with {@link PlcField} are always done against the "live" values
 * from the PLC.
 * <p>
 * A connected @{@link PlcEntity} can be disconnected calling {@link #disconnect(Object)}, then it behaves like the
 * regular Pojo it was before.
 * <p>
 * All invocations on the getters are forwarded to the
 * {@link PlcEntityInterceptor#interceptGetter(Object, Method, Callable, String, PlcDriverManager, AliasRegistry, Map, Map)}
 * method.
 */
public class PlcEntityManager {

    private static final Logger LOGGER = LoggerFactory.getLogger(PlcEntityManager.class);

    public static final String PLC_ADDRESS_FIELD_NAME = "_plcAddress";
    static final String DRIVER_MANAGER_FIELD_NAME = "_driverManager";
    static final String ALIAS_REGISTRY = "_aliasRegistry";
    public static final String LAST_FETCHED = "_lastFetched";
    public static final String LAST_WRITTEN = "_lastWritten";

    private final PlcDriverManager driverManager;
    private final SimpleAliasRegistry registry;

    public PlcEntityManager() {
        this(new PlcDriverManager());
    }

    public PlcEntityManager(PlcDriverManager driverManager) {
        this(driverManager, new SimpleAliasRegistry());
    }

    public PlcEntityManager(PlcDriverManager driverManager, SimpleAliasRegistry registry) {
        this.driverManager = driverManager;
        this.registry = registry;
    }

    public <T> T read(Class<T> clazz, String address) throws OPMException {
        T connect = connect(clazz, address);
        disconnect(connect);
        return connect;
    }

    public <T> T write(Class<T> clazz, String address, T object) throws OPMException {
        T merge = merge(clazz, address, object);
        disconnect(merge);
        return merge;
    }

    /**
     * Returns a connected proxy.
     *
     * @param clazz clazz to be connected.
     * @param <T>   type of param {@code clazz}.
     * @return a connected entity.
     * @throws OPMException when proxy can't be build.
     */
    public <T> T connect(Class<T> clazz, String address) throws OPMException {
        return connect(clazz, address, null);
    }


    /**
     * Returns a connected proxy.
     *
     * @param clazz clazz to be connected.
     * @param <T>   type of param {@code clazz}.
     * @return a connected entity.
     * @throws OPMException when proxy can't be build.
     */
    public <T> T merge(Class<T> clazz, String address, T instance) throws OPMException {
        return connect(clazz, address, instance);
    }

    private <T> T connect(Class<T> clazz, String address, T existingInstance) throws OPMException {
        OpmUtils.getPlcEntityAndCheckPreconditions(clazz);
        try {
            // Use Byte Buddy to generate a subclassed proxy that delegates all PlcField Methods
            // to the intercept method
            T instance = new ByteBuddy()
                .subclass(clazz)
                .defineField(PLC_ADDRESS_FIELD_NAME, String.class, Visibility.PRIVATE)
                .defineField(DRIVER_MANAGER_FIELD_NAME, PlcDriverManager.class, Visibility.PRIVATE)
                .defineField(ALIAS_REGISTRY, AliasRegistry.class, Visibility.PRIVATE)
                .defineField(LAST_FETCHED, Map.class, Visibility.PRIVATE)
                .defineField(LAST_WRITTEN, Map.class, Visibility.PRIVATE)
                .method(not(isDeclaredBy(Object.class))).intercept(MethodDelegation.to(PlcEntityInterceptor.class))
                .make()
                .load(Thread.currentThread().getContextClassLoader())
                .getLoaded()
                .getConstructor()
                .newInstance();
            // Set connection value into the private field
            FieldUtils.writeDeclaredField(instance, PLC_ADDRESS_FIELD_NAME, address, true);
            FieldUtils.writeDeclaredField(instance, DRIVER_MANAGER_FIELD_NAME, driverManager, true);
            FieldUtils.writeDeclaredField(instance, ALIAS_REGISTRY, registry, true);
            Map<String, Instant> lastFetched = new HashMap<>();
            FieldUtils.writeDeclaredField(instance, LAST_FETCHED, lastFetched, true);
            Map<String, Instant> lastWritten = new HashMap<>();
            FieldUtils.writeDeclaredField(instance, LAST_WRITTEN, lastWritten, true);

            // Initially fetch all values
            if (existingInstance == null) {
                PlcEntityInterceptor.refetchAllFields(instance, driverManager, address, registry, lastFetched);
            } else {
                FieldUtils.getAllFieldsList(clazz).stream()
                    .peek(field -> field.setAccessible(true))
                    .forEach(field -> setValueToField(field, instance, getValueFromField(field, existingInstance)));

                PlcEntityInterceptor.writeAllFields(instance, driverManager, address, registry, lastWritten);
            }

            return instance;
        } catch (NoSuchMethodException | InvocationTargetException | InstantiationException | IllegalAccessException | IllegalAccessError e) {
            throw new OPMException("Unable to instantiate Proxy", e);
        }
    }

    private Object getValueFromField(Field field, Object object) {
        try {
            return field.get(object);
        } catch (IllegalAccessException e) {
            throw new PlcRuntimeException(e);
        }
    }

    private void setValueToField(Field field, Object object, Object value) {
        try {
            field.set(object, value);
        } catch (IllegalAccessException e) {
            throw new PlcRuntimeException(e);
        }
    }

    /**
     * Disconnects the given instance.
     *
     * @param entity Instance of a PlcEntity.
     * @throws OPMException Is thrown when the plc is already disconnected or no entity.
     */
    public void disconnect(Object entity) throws OPMException {
        // Check if this is an entity
        PlcEntity annotation = entity.getClass().getSuperclass().getAnnotation(PlcEntity.class);
        if (annotation == null) {
            throw new OPMException("Unable to disconnect Object, is no entity!");
        }
        try {
            Object manager = FieldUtils.readDeclaredField(entity, DRIVER_MANAGER_FIELD_NAME, true);
            if (manager == null) {
                throw new OPMException("Instance is already disconnected!");
            }
            FieldUtils.writeDeclaredField(entity, DRIVER_MANAGER_FIELD_NAME, null, true);
        } catch (IllegalAccessException e) {
            throw new OPMException("Unable to fetch driverManager instance on entity instance", e);
        }
    }

}
