/*
 * Copyright (C) 2010 Teleal GmbH, Switzerland
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package org.teleal.cling.binding.annotations;

import org.teleal.cling.binding.LocalServiceBinder;
import org.teleal.cling.binding.LocalServiceBindingException;
import org.teleal.cling.model.ValidationError;
import org.teleal.cling.model.ValidationException;
import org.teleal.cling.model.action.ActionExecutor;
import org.teleal.cling.model.action.QueryStateVariableExecutor;
import org.teleal.cling.model.meta.Action;
import org.teleal.cling.model.meta.DeviceService;
import org.teleal.cling.model.meta.LocalDeviceService;
import org.teleal.cling.model.meta.LocalService;
import org.teleal.cling.model.meta.QueryStateVariableAction;
import org.teleal.cling.model.meta.StateVariable;
import org.teleal.cling.model.state.FieldStateVariableAccessor;
import org.teleal.cling.model.state.GetterStateVariableAccessor;
import org.teleal.cling.model.state.StateVariableAccessor;
import org.teleal.cling.model.types.ServiceId;
import org.teleal.cling.model.types.ServiceType;
import org.teleal.cling.model.types.UDAServiceId;
import org.teleal.cling.model.types.UDAServiceType;
import org.teleal.cling.model.types.csv.CSV;
import org.teleal.common.util.Reflections;

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.net.URI;
import java.net.URL;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.logging.Logger;


public class AnnotationLocalServiceBinder implements LocalServiceBinder {

    private static Logger log = Logger.getLogger(AnnotationLocalServiceBinder.class.getName());

    public DeviceService read(Class<?> clazz) throws LocalServiceBindingException {
        log.fine("Reading and binding annotations of service implementation class: " + clazz);

        // Read the service ID and service type from the annotation
        if (clazz.isAnnotationPresent(UpnpService.class)) {

            UpnpService annotation = clazz.getAnnotation(UpnpService.class);
            UpnpServiceId idAnnotation = annotation.serviceId();
            UpnpServiceType typeAnnotation = annotation.serviceType();

            ServiceId serviceId = idAnnotation.namespace().equals(UDAServiceId.DEFAULT_NAMESPACE)
                    ? new UDAServiceId(idAnnotation.value())
                    : new ServiceId(idAnnotation.namespace(), idAnnotation.value());

            ServiceType serviceType = typeAnnotation.namespace().equals(UDAServiceType.DEFAULT_NAMESPACE)
                    ? new UDAServiceType(typeAnnotation.value(), typeAnnotation.version())
                    : new ServiceType(typeAnnotation.namespace(), typeAnnotation.value(), typeAnnotation.version());

            boolean supportsQueryStateVariables = annotation.supportsQueryStateVariables();

            Set<Class> stringConvertibleTypes = readStringConvertibleTypes(annotation.stringConvertibleTypes());

            return read(clazz, serviceId, serviceType, supportsQueryStateVariables, stringConvertibleTypes);
        } else {
            throw new LocalServiceBindingException("Given class is not an @UpnpService");
        }
    }

    public DeviceService read(Class<?> clazz, ServiceId id, ServiceType type,
                                   boolean supportsQueryStateVariables, Class[] stringConvertibleTypes) throws LocalServiceBindingException {
        return read(clazz, id, type, supportsQueryStateVariables, new HashSet<Class>(Arrays.asList(stringConvertibleTypes)));
    }

    public DeviceService read(Class<?> clazz, ServiceId id, ServiceType type,
                                   boolean supportsQueryStateVariables, Set<Class> stringConvertibleTypes)
            throws LocalServiceBindingException {

        Map<StateVariable, StateVariableAccessor> stateVariables = readStateVariables(clazz, stringConvertibleTypes);
        Map<Action, ActionExecutor> actions = readActions(clazz, stateVariables, stringConvertibleTypes);

        // Special treatment of the state variable querying action
        if (supportsQueryStateVariables) {
            actions.put(new QueryStateVariableAction(), new QueryStateVariableExecutor());
        }

        LocalService localService;
        try {
            localService =
                    new LocalService(actions, stateVariables, stringConvertibleTypes, supportsQueryStateVariables);

        } catch (ValidationException ex) {
            log.severe("Could not validate device model: " + ex.toString());
            for (ValidationError validationError : ex.getErrors()) {
                log.severe(validationError.toString());
            }
            throw new LocalServiceBindingException("Validation of model failed, check the log");
        }

        return new LocalDeviceService(
                type,
                id,
                localService
        );
    }

    protected Set<Class> readStringConvertibleTypes(Class[] declaredTypes) throws LocalServiceBindingException {

        for (Class stringConvertibleType : declaredTypes) {
            if (!Modifier.isPublic(stringConvertibleType.getModifiers())) {
                throw new LocalServiceBindingException(
                        "Declared string-convertible type must be public: " + stringConvertibleType
                );
            }
            try {
                stringConvertibleType.getConstructor(String.class);
            } catch (NoSuchMethodException ex) {
                throw new LocalServiceBindingException(
                        "Declared string-convertible type needs a public single-argument String constructor: " + stringConvertibleType
                );
            }
        }
        Set<Class> stringConvertibleTypes = new HashSet(Arrays.asList(declaredTypes));

        // Some defaults
        stringConvertibleTypes.add(URI.class);
        stringConvertibleTypes.add(URL.class);
        stringConvertibleTypes.add(CSV.class);

        return stringConvertibleTypes;
    }

    protected Map<StateVariable, StateVariableAccessor> readStateVariables(Class<?> clazz, Set<Class> stringConvertibleTypes)
            throws LocalServiceBindingException {

        Map<StateVariable, StateVariableAccessor> map = new HashMap();

        // State variables declared on the class
        if (clazz.isAnnotationPresent(UpnpStateVariables.class)) {
            UpnpStateVariables variables = clazz.getAnnotation(UpnpStateVariables.class);
            for (UpnpStateVariable v : variables.value()) {

                if (v.name().length() == 0)
                    throw new LocalServiceBindingException("Class-level @UpnpStateVariable name attribute value required");

                String javaPropertyName = toJavaStateVariableName(v.name());

                Method getter = Reflections.getGetterMethod(clazz, javaPropertyName);
                Field field = Reflections.getField(clazz, javaPropertyName);

                StateVariableAccessor accessor = null;
                if (getter != null && field != null) {
                    accessor = variables.preferFields() ?
                            new FieldStateVariableAccessor(field)
                            : new GetterStateVariableAccessor(getter);
                } else if (field != null) {
                    accessor = new FieldStateVariableAccessor(field);
                } else if (getter != null) {
                    accessor = new GetterStateVariableAccessor(getter);
                } else {
                    log.finer("No field or getter found for state variable, skipping accessor: " + v.name());
                }

                StateVariable stateVar =
                        new AnnotationStateVariableBinder(v, v.name(), accessor, stringConvertibleTypes)
                                .createStateVariable();

                map.put(stateVar, accessor);
            }
        }

        // State variables declared on fields
        for (Field field : Reflections.getFields(clazz, UpnpStateVariable.class)) {

            UpnpStateVariable svAnnotation = field.getAnnotation(UpnpStateVariable.class);

            StateVariableAccessor accessor = new FieldStateVariableAccessor(field);

            StateVariable stateVar = new AnnotationStateVariableBinder(
                    svAnnotation,
                    svAnnotation.name().length() == 0
                            ? toUpnpStateVariableName(field.getName())
                            : svAnnotation.name(),
                    accessor,
                    stringConvertibleTypes
            ).createStateVariable();

            map.put(stateVar, accessor);
        }

        // State variables declared on getters
        for (Method getter : Reflections.getMethods(clazz, UpnpStateVariable.class)) {

            String propertyName = Reflections.getMethodPropertyName(getter.getName());
            if (propertyName == null) {
                throw new LocalServiceBindingException(
                        "Annotated method is not a getter method (: " + getter
                );
            }

            if (getter.getParameterTypes().length > 0)
                throw new LocalServiceBindingException(
                        "Getter method defined as @UpnpStateVariable can not have parameters: " + getter
                );

            UpnpStateVariable svAnnotation = getter.getAnnotation(UpnpStateVariable.class);

            StateVariableAccessor accessor = new GetterStateVariableAccessor(getter);

            StateVariable stateVar = new AnnotationStateVariableBinder(
                    svAnnotation,
                    svAnnotation.name().length() == 0
                            ?
                            toUpnpStateVariableName(propertyName)
                            : svAnnotation.name(),
                    accessor,
                    stringConvertibleTypes
            ).createStateVariable();

            map.put(stateVar, accessor);
        }

        return map;
    }

    protected Map<Action, ActionExecutor> readActions(Class<?> clazz,
                                                      Map<StateVariable, StateVariableAccessor> stateVariables,
                                                      Set<Class> stringConvertibleTypes)
            throws LocalServiceBindingException {

        Map<Action, ActionExecutor> map = new HashMap();

        for (Method method : Reflections.getMethods(clazz, UpnpAction.class)) {
            AnnotationActionBinder actionBinder =
                    new AnnotationActionBinder(method, stateVariables, stringConvertibleTypes);
            actionBinder.appendAction(map);
        }

        return map;
    }

    // TODO: I don't like the exceptions much, user has no idea what to do

    public static String toUpnpStateVariableName(String javaName) {
        if (javaName.length() < 1) {
            throw new IllegalArgumentException("Variable name must be at least 1 character long");
        }
        return javaName.substring(0, 1).toUpperCase() + javaName.substring(1);
    }

    public static String toJavaStateVariableName(String upnpName) {
        if (upnpName.length() < 1) {
            throw new IllegalArgumentException("Variable name must be at least 1 character long");
        }
        return upnpName.substring(0, 1).toLowerCase() + upnpName.substring(1);
    }


    public static String toUpnpActionName(String javaName) {
        if (javaName.length() < 1) {
            throw new IllegalArgumentException("Action name must be at least 1 character long");
        }
        return javaName.substring(0, 1).toUpperCase() + javaName.substring(1);
    }

    public static String toJavaActionName(String upnpName) {
        if (upnpName.length() < 1) {
            throw new IllegalArgumentException("Variable name must be at least 1 character long");
        }
        return upnpName.substring(0, 1).toLowerCase() + upnpName.substring(1);
    }

}