/*
 * Groovy UpCloud library - Core Module
 * Copyright (C) 2018  Mikko VÃ¤rri
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU 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 General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package fi.linuxbox.upcloud.core

import fi.linuxbox.upcloud.http.spi.META
import groovy.util.logging.Slf4j

import static fi.linuxbox.upcloud.core.NamingContract.javaClassOrPropertyToJsonProperty
import static fi.linuxbox.upcloud.core.NamingContract.javaClassToJavaProperty
import static fi.linuxbox.upcloud.core.NamingContract.jsonPropertyToJavaClass
import static fi.linuxbox.upcloud.core.NamingContract.jsonPropertyToJavaProperty
import static fi.linuxbox.upcloud.core.ResourceLoader.instantiateResourceClass
import static fi.linuxbox.upcloud.core.ResourceUtil.resourceProperties

/**
 * Model for a resource in UpCloud, e.g. server, storage device, IP address, etc.
 * <p>
 *     Together with the {@link Session} class, this class is the most core of the Groovy UpCloud library.  You
 *     probably don't use this directly, but you could.  This class allows for dynamic creation of UpCloud resource
 *     representations and their properties.
 * </p>
 * <p>
 *     For example, at the time of this writing, the UpCloud account API returns {@code credits} and {@code username}
 *     properties, but no {@code email} property.  Not saying that it should, but it could be useful.  Now, if a
 *     future revision of the API adds that property, it would be immediately available in the {@code Account}
 *     resource of this library, even without a library update.
 * </p>
 * <p>
 *     For example, if the server responded with:
 * </p>
 * <pre><code class="http">
 *     HTTP/1.1 200 OK
 *     Content-Type: application/json; charset=UTF-8
 *
 *     {
 *         "account": {
 *             "credits": 1000,
 *             "username": "foo_bar",
 *             "email": "juliet.capulet@protagonist.org"
 *         }
 *     }
 * </code></pre>
 * <p>
 *     Above, the {@code email} JSON field is a new one.  In the application code, it would be available like so:
 * </p>
 * <pre><code class="groovy">
 *     upcloud.account { response ->
 *         assert response.account.email == "juliet.capulet@protagonist.org"
 *     }
 * </code></pre>
 *
 * <p>
 *     Similarly, if a future revision of the UpCloud API would add a completely new property to, say, a server,
 *     represented as a new kind of resource, then it would be visible in the responses generated by this library.
 *     For example:
 * </p>
 * <pre><code class="http">
 *     HTTP/1.1 200 OK
 *     Content-Type: application/json; charset=UTF-8
 *
 *     {
 *         "server": {
 *             ...
 *             "docker_containers": {
 *                 "docker_container": [
 *                     {
 *                         "image": "ubuntu:latest",
 *                         "state": "running"
 *                     },
 *                     {
 *                         "image": "nginx:latest",
 *                         "state": "running"
 *                     }
 *                 ]
 *             },
 *             ...
 *         }
 *     }
 * </code></pre>
 * <p>
 *     Above, the {@code docker_containers} JSON field is an imaginary one, but because the server returned it, it
 *     would be available in the application callback as follows:
 * <p>
 * <pre><code class="groovy">
 *     server.load { response ->
 *       assert response.server instanceof fi.linuxbox.upcloud.resource.Server
 *       // Above is old news.
 *       // Now for the fun part:
 *       assert response.server.dockerContainers instanceof List
 *       def c = response.server.dockerContainers
 *       assert c.every { it.class.name == 'fi.linuxbox.upcloud.resource.DockerContainer' }
 *       assert c[0].state == "running"
 *     }
 * </code></pre>
 * <p>
 *     Here, it is not possible to use {@code instanceof} because the {@code DockerContainer} class is not available
 *     at compile time, i.e. your compiler would complain about the reference.  But at runtime the class is as real
 *     as any other resource class.
 * </p>
 */
@Slf4j
class Resource {
    final HTTPFacade<?> HTTP
    final META META

    /**
     * Designated, and only, constructor.
     *
     * @param kwargs.HTTP The {@link HTTPFacade} instance.  This is used by the resource specific API wrappers, not directly by this class.
     * @param kwargs.META The META instance.  This is received from the HTTP implementation.
     * @param kwargs.repr The Map<String, Object> intermediary representation from the JSON implementations.
     */
    Resource(final Map kwargs = [:]) {
        // the second arg (register=false) makes this metaClass instance scoped
        metaClass = new ExpandoMetaClass(this.class, false, true)
        metaClass.initialize()

        HTTP = kwargs.HTTP
        META = kwargs.META

        final Map<String, ?> map = kwargs.remove('repr') as Map<String, ?>
        map?.each { final String key, final Object value ->
            if (value == null)
                return
            Object propertyValue
            switch (value) {
                case ListWrapper:
                    final String className = jsonPropertyToJavaClass(ListWrapper.getKey(value))
                    propertyValue = ListWrapper.getElements(value) collect {
                        if (it instanceof Map<String, ?>)
                            instantiateResourceClass(className, [*:kwargs, repr: it])
                        else
                            it
                    }
                    break
                case Map/*<String, ?>*/: // groovy cannot handle the generics syntax here
                    final String className = jsonPropertyToJavaClass(key)
                    propertyValue = instantiateResourceClass(className, [*:kwargs, repr: value])
                    break
                default:
                    propertyValue = value
                    break
            }

            final propertyName = jsonPropertyToJavaProperty(key)
            final property = this.metaClass.getMetaProperty(propertyName)
            if (property == null) {
                if (this.class !== Resource)
                    log.trace("Assigning dynamic property $propertyName for class ${this.class.simpleName}")
                this.metaClass.setProperty(this, propertyName, propertyValue)
            } else if (!property.type.isAssignableFrom(propertyValue.class)) {
                // Try to do Groovy type conversion, like Integer to Long or BigDecimal
                final v = propertyValue.asType(property.type)
                if (v != null)
                    this.metaClass.setProperty(this, propertyName, v)
                else
                    log.warn("${this.class.simpleName} property $propertyName is of type ${property.type.simpleName}, got ${propertyValue.class.simpleName}")
            } else {
                this.metaClass.setProperty(this, propertyName, propertyValue)
            }
        }
    }

    /**
     * Converts this resource to a Map<String, Object> representation for JSON generation.
     *
     * <p>
     * This is used as <code>resource as Map</code> and meant to be invoked only from the Session class, just before the resource
     * is put into the queue of to-be-sent requests.
     * </p>
     *
     * <p>
     * Instead of using direct object-to-JSON mapping, this intermediary representation is used because
     * </p>
     * <ul>
     *     <li>
     *         This allows for a quick snapshot of the resource.  Generating the JSON could happen in a background thread.
     *     </li>
     *     <li>
     *         This allows the JSON generator to be stupid.  It doesn't have to deal with conversion between Java and
     *         JSON style of property names.  Also, it doesn't have to know how to filter the properties, or how to
     *         introspect for them in the first place.
     *     </li>
     *     <li>
     *         This allows the JSON to be changed to Protobuf or XML more easily.  Or the JSON implementation to change
     *         mode easily.  This is because the requirements for those modules is very low.
     *     </li>
     * </ul>
     *
     * @param clazz Map.  Nothing else supported.
     * @return Representation of this resource.
     */
    Object asType(Class clazz) {
        resourceProperties(this).collectEntries { final String key, final Object value ->
            final String property_name = javaClassOrPropertyToJsonProperty(key)
            [(property_name): value.with {
                switch (it) {
                    case { it instanceof List && !it.isEmpty() }: // non-empty List
                        String type_name_str
                        Closure mapper
                        // Assume that all elements are of the same type
                        final Object firstElement = it[0]
                        switch (firstElement) {
                            case Resource:
                                // this.prices = [ Zone(), ... ]
                                // map[prices] = [ zone: [ [:], ... ] ]
                                // this.timezones = [ "asd", "dsa", ... ]
                                // map[timezones] = [ timezone: [ "asd", "dsa", ... ] ]
                                type_name_str = javaClassOrPropertyToJsonProperty(firstElement.class.simpleName)
                                mapper = { it as Map }
                                break
                            case String:
                                // HACK: only places I know of where we need this:
                                //       * server creation: [ssh_keys: [ssh_key: [key1, key2]]]
                                //       * tag creation: [servers: [server: [uuid1, uuid2]]]
                                //       * tag update: [servers: [server: [uuid1, uuid2]]]
                                //       so naive unpluralization of the property_name (ssh_keys or servers)
                                //       will do for those.
                                type_name_str = property_name[0..-2]
                                mapper = { it }
                                break
                            default:
                                throw new UnsupportedOperationException("Only lists of Resources or Strings is currently supported!!!")
                        }
                        // the parentheses around the key force the GString to evaluate to String
                        return [(type_name_str): it.collect(mapper)]
                    case Resource:
                        return it as Map
                    default:
                        return it
                }
            }]
        }
    }

    /**
     * Returns a string representation of the UpCloud resource.
     * <p>
     *     This is meant to be human readable, not as an API.
     * </p>
     *
     * @return A string representation of the resource
     */
    @Override
    String toString() {
        this.class.simpleName + "(" +
                resourceProperties(this)
                        .collect { k, v -> "$k: $v" }
                        .sort()
                        .join(', ') +
                ")"
    }

    /**
     * MOP method that allows resource properties to be missing.
     *
     * <p>
     * Allows for code like <code>if (server.name) ...</code> without actually defining Java Bean property for the name.
     * </p>
     *
     * @param name Name of the property.
     * @return Value of the property or <code>null</code>.
     */
    def propertyMissing(final String name) {
        // Help the trait proxies see all the properties (we used to return null here, but the proxies didn't work).
        if (this.hasProperty(name))
            this.properties[name]
        else
            throw new MissingPropertyException(name, this.class)
    }

    /**
     * MOP method that allows resource properties to be set.
     *
     * <p>
     * Allows for code like <code>server.name = "my server"</code> without actually defining Java Bean property for the
     * name.
     * </p>
     *
     * @param name Name of the property.
     * @param arg Value of the property.
     * @return Value of the property.
     */
    def propertyMissing(final String name, final arg) {
        this.metaClass."$name" = arg
    }

    /**
     * Represents a list wrapper.
     * <p>
     *      A list wrapper is a Map that has only one key and the value
     *      corresponding to that key is a list.  This is a convention in the
     *      UpCloud API.
     * </p>
     * <p>
     *     A list of prices is one example (in JSON below):
     * <p>
     * <pre><code class="json">
     *       { "prices":
     *           { "zone":
     *               [ { ... first zone object ... },
     *                 { ... another zone object ... },
     *                 ...
     *               ]
     *           }
     *       }
     * </code></pre>
     * <p>
     *     Or a list of IP addresses:
     * </p>
     * <pre><code class="json">
     *       { "ip_addresses":
     *           { "ip_address":
     *               [ "123.123.123.123",
     *                 "234.234.234.234"
     *               ]
     *           }
     *       }
     * </code></pre>
     * <p>
     *     In the above examples, the element class names would be "Zone" and
     *     "IpAddress", and the elements would be the list of zones and list
     *     of IP address strings.
     * </p>
     */
    private static class ListWrapper {
        /**
         * Answers whether the object looks like a list wrapper.
         *
         * @param value Candidate object
         * @return {@code true} if candidate looks like a list wrapper,
         * {@code false} otherwise
         */
        private static Boolean isCase(final Object value) {
            value instanceof Map<String, ?> && value.size() == 1 && value.values()[0] instanceof List
        }
        /**
         *
         * @param self
         * @return
         */
        private static List getElements(final Object self) {
            final String key = getKey(self)
            (List) ((Map)self).get(key)
        }
        /**
         *
         * @param self
         * @return
         */
        private static String getKey(final Object self) {
            assert isCase(self)
            ((Map)self).keySet()[0]
        }
    }
}
