# Copyright (c) 2019-2021 Kevin Crouse
#
# Licensed 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.
#
# @license: http://www.apache.org/licenses/LICENSE-2.0
# @author: Kevin Crouse (krcrouse@gmail.com)

import re
import warnings
import datetime
import json

from pprint import pprint
import googleapps
import googleapiclient
import time

class DefaultProperty:
    """ An override to the @property decorator that simplifies the property process and allows for a readonly definition.

    Args:
        propertyname (str): The name of the property. This will be set on all objects. It will also create a private instance variable preceded by an underscore where the value is actually stored.
        readonly (bool): Whether this property is readonly. Default is false.
    """

    def __init__(self, propertyname, readonly=False):

        self.property = propertyname
        self.readonly = readonly
        self.attr = '_' + propertyname

    def __get__(self, obj, cls=None):
        """ When accessed, return the current value of the attributed. When called in class contexts, return the property object. """
        if instance is None:
            return(self)
        return( getattr(obj, self.attr ))

    def __set__(self, obj, value):
        """ The function for setting the property, which sets the attribute .

            Raises AttributeError if this is a readonly property.
        """
        if self.readonly:
            raise AttributeError("Attempted to set '" + self.property + "' on "+type(obj).__name__+", which is a read-only attribute.")
        return( setattr(obj, self.attr, value))

    def __delete__(self, obj):
        """ The function for deleting the object.

        Raises AttributeError if this is a readonly property.
        """
        if self.readonly:
            raise AttributeError("Attempted to delete'" + self.property + "' on "+type(obj).__name__+", which is a read-only attribute.")
        return( delattr(obj, self.attr))

class RemoteProperty(DefaultProperty):
    ''' A property class for Python classes that represent remote objects. This is an extension of the property interface with the following:

    - the setter class record_value_change(property, value), which will allow the class to make/queue any request that this change requires.

    - TODO: still trying to figure out what the appropriate functionality for del is
    '''

    def __get__(self, obj, cls=None):
        """ Gets the value. In cases when the value has not been defined, it attempts to commit the object (if permitted) and checks again - ie. perhaps this is a new object and some properties are defined when the object is first saved remotely (like an autogenerated ID or a date created), so the commit is attempted.

        Raises AttributeError if the attribute hasn't previously been defined (or still is not definied if autocommit is permitted and successful), because in these cases it cannot be known what the value actually is.
        """

        if not hasattr(obj, self.attr):
            if obj.commit_policy >= CommitType.defer_commit:
                if obj.debug:
                    print("-- Automatic commit() triggered by call on "+type(obj).__name__ +" object to property " + self.attr + " before it has been fetched")
                obj.commit()
                if not hasattr(obj, self.attr):
                    raise AttributeError(type(obj).__name__ + " property '"+str(self.property)+"' is not available for this object, even after attempting to commit the current state")
            else:
                raise AttributeError(type(obj).__name__ + " property '"+str(self.property)+"' is not available. Have you committed the necessary service requests to get this information from the remote source?")
        return(getattr(obj, self.attr))

    def __set__(self, obj, value):
        """ After setting, calls record_value_change on the object to register the change. """
        result = super().__set__(obj, value)
        obj.record_value_change(self.property)
        return(result)

    def __delete__(self, obj):
        """ deletes the value.
             - if a current value has been set/recorded, that value is cleared. Fully cleared from the queue. It is NOT replaced by "None"
            - we should not communicate this property value to the server on any future commit()
            - we should automatically set the local value on load()
            - hasattr() will return false, and so if the property is requested before the next load() or commit(), the AttributeError will be thrown

        Notes:
            TODO: verify this is correct and desired.

            Delete could work in several different ways.... Which is the most correct functionality for delete?
                a. Delete is remotely-attached: By deleting the property, the call is intended to clear the value on the Remote source.
                b. (current implementation) Delete is locally-attached: The delete should clear the local value only, indicating that load() will overwrite it locally and commit() will *not* send anything about this property to the Remote source
        """
        result = super().__delete__(obj)
        obj.record_value_change(self.property, cleared=True)
        return( result )


def classproperties(class_obj, *args, **kwargs):
    """ This is a decorator for a class that definies a series of properties """

    def set_property(cls, property, definition):
        if hasattr(cls, property):
            initial_value = getattr(cls, property)

        setattr(cls, property, definition)

        if initial_value is not None:
            # TODO: if the class has a value set, should it do the full registration (which might be important for a complicated setter with side effects)
            # or not?  Currently, not. Initial/default values do not trigger any side effects.
            setattr(class_obj, definition.attr, initial_value)

    # by default, a list of properties become DefaultProperty
    if hasattr(class_obj, 'properties') and 'properties' not in kwargs:
        for property in class_obj.properties:
            set_property(class_obj, property, DefaultProperty(property))

    # any other args parameter is a custom-named list of properties that are not "custom properties", so they also become DefaultProperty
    for property_list in args:
        properties = getattr(class_obj, property_list)
        for property in properties:
            set_property(class_obj, property, DefaultProperty(property))

    # any kwargs is a list of "custom properties" and set the list name to the property class they should be defined to.
    # Note that setting the default 'properties' list will be handled here because of the 'not in kwargs' in the default
    # any propety list with a standalone "ro" will be set to readonly, so 'ro_properties', 'props_ro', 'auto_ro_props' all qualify
    for property_list, propdefinition in kwargs.items():
        readonly = re.search(r'(^|\_)ro(\_|$)', property_list)
        for property in getattr(class_obj, property_list):
            setattr(class_obj, property, propdefinition(property, readonly=readonly))

def googleappclient(cls):
    # This is just shorthand for a specific interface for the classproperties
    classproperties(cls, api_properties=RemoteProperty, api_ro_properties=RemoteProperty)
    return(cls)

class ServiceRequest():
    def __init__(self, api, api_version, resource, function, callback, parameters):
        self.api=api
        self.api_version = api_version
        self.resource = resource
        self.function = function
        self.callback = callback
        self.parameters = parameters

    def append(self, *param_path):
        json = param_path[-1]
        ref = self.parameters
        for var in param_path[:-1]:
            ref = ref[var]
        ref.append(json)


import collections
import enum
class CommitType(enum.IntEnum):
    no_commit = 0
    defer_commit = 1 # don't commit without explicit user statement.  load() and save() are exceptions
    entity_commit = 2 # commit all major operations, such as creating or deleting a new entity. Do not update minor property changes
    auto_commit = 3  # commit everything.

class ServiceClient():
    services = googleapps.ServiceManager()
    default_api = None
    default_api_version = None
    default_scope = None
    debug=False
    debug_commit=False

    api_properties = ()
    api_properties = ()
    api_translation = ()

    commit_policy=CommitType.auto_commit
    attempt_recover=True
    retry_count=5
    quotas = ({'timespan':'min', 'count':100})

    def __init__(self, **kwargs):

        self._commit_policy = self.__class__.commit_policy
        for arg in kwargs:
            if arg not in self.api_properties and arg not in self.api_ro_properties:
                raise TypeError("__init__() got unexpected keyword argument '"+arg+"'")
            setattr(self, '_' + arg, kwargs[arg])

        self.clear_queue()

    @classmethod
    def _generate_api_properties(cls):
        for property in cls.api_properties:
                setattr(cls, property, autoproperty(property))

    def _initialize_from_template(self):
        for property in self.new_property_template:
            attr = '_' + property
            if not hasattr(self, attr):
                setattr(self, property, self.new_property_template[property])


    def commit(self):
        # now do everything
        for request in self.request_queue:
            # now parse the parameters
            response = self._send_request(request)

        self.clear_queue()

    def make_request(self, resource, function, callback=None, api=None, api_version=None, **parameters):
        if not api:
            api = self.default_api
        if not api_version:
            api_version = self.default_api_version
        return(self._send_request(ServiceRequest(api, api_version, resource, function, callback, parameters)))

    def add_request(self, resource, function, callback=None, api=None, api_version=None, commit_on=CommitType.entity_commit, **parameters):
        if not api:
            api = self.default_api
        if not api_version:
            api_version = self.default_api_version
        request = ServiceRequest(api, api_version, resource, function, callback, parameters)
        if self.commit_policy >= commit_on:
            self._send_request(request)
        else:
            self.request_queue.append(request)

    def add_update_request(self, *args, commit_on=CommitType.auto_commit, **parameters):
        # same as add_request, except the commit policy is more stringent
        self.add_request(*args, commit_on=commit_on, **parameters)


    @property
    def last_request(self):
        if len(self.request_queue):
            return(self.request_queue[-1])


    def clear_queue(self):
        self.request_queue = []


    @classmethod
    def _prepare_json(cls, parameters, object=None):
        result = {}
        if type(parameters) is not dict:
            return(parameters)
        for key, value in parameters.items():
            if callable(value):
                if object:
                    result[key] = value(object)
                else:
                    raise Exception("JSON parameter " + str(key) + " is set as a deferred calculation, but the prepare_json was called in class context without an object on which to call the function")
            elif type(value) is dict:
                result[key] = cls._prepare_json(value, object=object)
            elif type(value) in (list, tuple):
                result[key] = []
                for v in value:
                    # this is naive. We don't really think v is always going to be json, do we?
                    result[key].append(cls._prepare_json(v, object=object))
            elif type(value) is datetime.datetime:
                result[key] = googleapps.to_rfctime(value)
            else:
                result[key] = value
        return(result)

    def prepare_json(self, parameters):
        return(self.__class__._prepare_json(parameters, object=self))

    @property
    def default_service(self):
        #TODO: we either needto change the class variables to 'api' and 'api_version' or handle instance variables here.
        return(self.get_service(self.default_api, self.default_api_version))

    @classmethod
    def get_service(cls, api=None, api_version=None):
        if api:
            if not api_version and api == cls.api:
                return(cls.services.load_service(cls.default_api, cls.default_api_version, cls.default_scope))

            return(cls.services.load_service(api, api_version))
        else:
            return(cls.services.load_service(cls.default_api, cls.default_api_version, cls.default_scope))


    def _send_request(self, request):
        return(self.__class__.make_class_request(request, object=self, attempt_recover=self.attempt_recover, retry_count=self.retry_count, debug=self.debug))

    @classmethod
    def make_class_request(cls, request, object=None, debug=None, attempt_recover=True, retry_count=4):

        service = cls.get_service(request.api, request.api_version)

        if type(request.resource) is list:
            resource = service
            for res in request.resource:
                resource = getattr(resource, res)()
        else:
            resource = getattr(service, request.resource)()
        function = getattr(resource, request.function)
        # remember we can defer by providing closues.  It's not timet o execute them

        request_json = cls._prepare_json(request.parameters, object=object)
        if debug:
            print("---------------\n " + str(request.resource) + " is about to commit a '"+str(request.function)+"' request:")
            pprint(request_json)
        try:

            response = function(**request_json).execute()
        except googleapiclient.errors.HttpError as he:
            if not attempt_recover:
                raise(he)

            hejson = json.loads(he.content)

            # Other errors that might be added as specific handling
            # "The caller does not have permission"
            #
            #-----
            if he.resp.status in (400,403) or re.search('Invalid (request|JSON|value)', hejson['error']['message']):
                # just propogate the error
                print("Error in service request. Parameters sent in request: ", request.parameters)
                raise
            elif re.search(r'Insufficient Permission: Request had insufficient authentication scopes.', hejson['error']['message']):
                print("The scopes you have authorized are not sufficient enough for the function called. You will need to delete the cached authentication file and reauthorize with appropriate scopes.")
                raise
            elif re.search("Insufficient tokens for quota", hejson['error']['message']):
                print("Quota exceeded. Attempting to sleep for afew minutes and trying again")
                wait_time = 60
            else:
                print("HttpError caught for " + str(request.resource) + " execution of " + str(request.function) + ": " + str(he))
                print("-- Attempting wait and retry -- ")
                wait_time = 1

            counter = retry_count
            while counter:
                time.sleep(wait_time)
                print('..... retrying request (retry='+str(retry_count - counter + 1)+') .....')
                try:
                    response = function(**request_json).execute()
                    #success! Exit loop!
                    # note counter is still > 0
                    break
                except googleapiclient.errors.HttpError as he:
                    if cls.debug:
                        import pdb
                        pdb.set_trace()
                    # decrease the counter, double the wait time
                    counter -= 1
                    wait_time *= 2

            if not counter:
                print("Failed to recover. Exiting.")
                raise

        if debug:
            print("\n***** Response Received: *****")
            pprint(response)
            print("---------------\n")
        if request.callback:
            callback = request.callback
            if debug:
                print("----> Callback provided: " + str(callback))
            callback(response)

        return(response)


    #
    #
    # I think this is deprecated
    #
    #

    def process_api_definition(self, request_function, values, parameters, *nested_parameters):
        request = {}
        keys = list(values.keys())
        for arg in keys:
            if arg not in parameters:
                raise Exception("Unknown parameter '" + arg + "' passed to " + request_function )

            if values[arg] is None:
                warnings.warn("In "+request_function+", parameter '" + arg + "' passed as None, which will be ignored" )
            else:
                request[googleapps.toCamel(arg)] = values[arg]
            del values[arg]


        for nested in nested_parameters:
            for nest, sub_parameters in nested.items():
                subrequest = self.process_api_definition(
                    request_function + ', subfunction' + nest,
                    values,
                    sub_parameters)
                if subrequest:
                    request[googleapps.toCamel(nest)] = subrequest

        if values:
            raise Exception("Unknown parameter-value ( "+str(values)+" ) provided to " + request_function )

        if request:
            return(request)
