# Copyright 2015 Tesora Inc.
# All Rights Reserved.
#
#    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.

from enum import Enum
import inspect
from proboscis import SkipTest
from time import sleep


class DataType(Enum):

    """
    Represent the type of data to add to a datastore.  This allows for
    multiple 'states' of data that can be verified after actions are
    performed by Trove.
    If new entries are added here, sane values should be added to the
    _fn_data dictionary defined in TestHelper.
    """

    # micro amount of data, useful for testing datastore logging, etc.
    micro = 1
    # another micro dataset (also for datastore logging)
    micro2 = 2
    # another micro dataset (also for datastore logging)
    micro3 = 3
    # another micro dataset (also for datastore logging)
    micro4 = 4
    # very tiny amount of data, useful for testing replication
    # propagation, etc.
    tiny = 5
    # another tiny dataset (also for replication propagation)
    tiny2 = 6
    # a third tiny dataset (also for replication propagation)
    tiny3 = 7
    # a forth tiny dataset (for cluster propagation)
    tiny4 = 8
    # small amount of data (this can be added to each instance
    # after creation, for example).
    small = 9
    # large data, enough to make creating a backup take 20s or more.
    large = 10


class TestHelper(object):

    """
    Base class for all 'Helper' classes.

    The Helper classes are designed to do datastore specific work
    that can be used by multiple runner classes.  Things like adding
    data to datastores and verifying data or internal database states,
    etc. should be handled by these classes.
    """

    # Define the actions that can be done on each DataType.  When adding
    # a new action, remember to modify _data_fns
    FN_ADD = 'add'
    FN_REMOVE = 'remove'
    FN_VERIFY = 'verify'
    FN_TYPES = [FN_ADD, FN_REMOVE, FN_VERIFY]

    # Artificial 'DataType' name to use for the methods that do the
    # actual data manipulation work.
    DT_ACTUAL = 'actual'

    def __init__(self, expected_override_name, report):
        """Initialize the helper class by creating a number of stub
        functions that each datastore specific class can chose to
        override.  Basically, the functions are of the form:
            {FN_TYPE}_{DataType.name}_data
        For example:
            add_tiny_data
            add_small_data
            remove_small_data
            verify_large_data
        and so on.  Add and remove actions throw a SkipTest if not
        implemented, and verify actions by default do nothing.
        These methods, by default, call the corresponding *_actual_data()
        passing in 'data_label', 'data_start' and 'data_size' as defined
        for each DataType in the dictionary below.
        """
        super(TestHelper, self).__init__()

        self._expected_override_name = expected_override_name
        self.report = report

        # For building data access functions
        # name/fn pairs for each action
        self._data_fns = {self.FN_ADD: {},
                          self.FN_REMOVE: {},
                          self.FN_VERIFY: {}}
        # Pattern used to create the data functions.  The first parameter
        # is the function type (FN_TYPE), the second is the DataType
        # or DT_ACTUAL.
        self.data_fn_pattern = '%s_%s_data'
        # Values to distinguish between the different DataTypes.  If these
        # values don't work for a datastore, it will need to override
        # the auto-generated {FN_TYPE}_{DataType.name}_data method.
        self.DATA_START = 'start'
        self.DATA_SIZE = 'size'
        self._fn_data = {
            DataType.micro.name: {
                self.DATA_START: 100,
                self.DATA_SIZE: 10},
            DataType.micro2.name: {
                self.DATA_START: 200,
                self.DATA_SIZE: 10},
            DataType.micro3.name: {
                self.DATA_START: 300,
                self.DATA_SIZE: 10},
            DataType.micro4.name: {
                self.DATA_START: 400,
                self.DATA_SIZE: 10},
            DataType.tiny.name: {
                self.DATA_START: 1000,
                self.DATA_SIZE: 100},
            DataType.tiny2.name: {
                self.DATA_START: 2000,
                self.DATA_SIZE: 100},
            DataType.tiny3.name: {
                self.DATA_START: 3000,
                self.DATA_SIZE: 100},
            DataType.tiny4.name: {
                self.DATA_START: 4000,
                self.DATA_SIZE: 100},
            DataType.small.name: {
                self.DATA_START: 10000,
                self.DATA_SIZE: 1000},
            DataType.large.name: {
                self.DATA_START: 100000,
                self.DATA_SIZE: 100000},
        }

        self._build_data_fns()

    #################
    # Utility methods
    #################
    def get_class_name(self):
        """Builds a string of the expected class name, plus the actual one
        being used if it's not the same.
        """
        class_name_str = "'%s'" % self._expected_override_name
        if self._expected_override_name != self.__class__.__name__:
            class_name_str += ' (using %s)' % self.__class__.__name__
        return class_name_str

    ################
    # Client related
    ################
    def get_client(self, host, *args, **kwargs):
        """Gets the datastore client. This isn't cached as the
        database may be restarted in between calls, causing
        lost connection errors.
        """
        return self.create_client(host, *args, **kwargs)

    def create_client(self, host, *args, **kwargs):
        """Create a datastore client.  This is datastore specific, so this
        method should be overridden if datastore access is desired.
        """
        raise SkipTest('No client defined')

    def get_helper_credentials(self):
        """Return the credentials that the client will be using to
        access the database.
        """
        return {'name': None, 'password': None, 'database': None}

    def ping(self, host, *args, **kwargs):
        """Try to connect to a given host and perform a simple read-only
        action.

        Return True on success or False otherwise.
        """
        pass

    ##############
    # Root related
    ##############
    def get_helper_credentials_root(self):
        """Return the credentials that the client will be using to
        access the database as root.
        """
        return {'name': None, 'password': None}

    ##############
    # Data related
    ##############
    def add_data(self, data_type, host, *args, **kwargs):
        """Adds data of type 'data_type' to the database.  Descendant
        classes should implement a function 'add_actual_data' that has the
        following signature:
            def add_actual_data(
                self,        # standard self reference
                data_label,  # label used to identify the 'type' to add
                data_start,  # a start count
                data_size,   # a size to use
                host,        # the host to add the data to
                *args,       # for possible future expansion
                **kwargs     # for possible future expansion
            ):
        The data_label could be used to create a database or a table if the
        datastore supports that.  The data_start and data_size values are
        designed not to overlap, such that all the data could be stored
        in a single namespace (for example, creating ids from data_start
        to data_start + data_size).

        Since this method may be called multiple times, the
        'add_actual_data' function should be idempotent.
        """
        self._perform_data_action(self.FN_ADD, data_type.name,
                                  host, *args, **kwargs)

    def remove_data(self, data_type, host, *args, **kwargs):
        """Removes all data associated with 'data_type'.  See
        instructions for 'add_data' for implementation guidance.
        """
        self._perform_data_action(self.FN_REMOVE, data_type.name,
                                  host, *args, **kwargs)

    def verify_data(self, data_type, host, *args, **kwargs):
        """Verify that the data of type 'data_type' exists in the
        datastore.  This can be done by testing edge cases, and possibly
        some random elements within the set.  See
        instructions for 'add_data' for implementation guidance.
        """
        self._perform_data_action(self.FN_VERIFY, data_type.name,
                                  host, *args, **kwargs)

    def _perform_data_action(self, fn_type, fn_name, host,
                             *args, **kwargs):
        """By default, the action is attempted 10 times, sleeping for 3
        seconds between each attempt.  This can be controlled by the
        retry_count and retry_sleep kwarg values.
        """
        retry_count = kwargs.pop('retry_count', 10) or 0
        retry_sleep = kwargs.pop('retry_sleep', 3) or 0

        fns = self._data_fns[fn_type]
        data_fn_name = self.data_fn_pattern % (fn_type, fn_name)
        attempts = -1
        while True:
            attempts += 1
            try:
                fns[data_fn_name](self, host, *args, **kwargs)
                break
            except SkipTest:
                raise
            except Exception as ex:
                self.report.log("Attempt %d to %s data type %s failed\n%s"
                                % (attempts, fn_type, fn_name, ex))
                if attempts > retry_count:
                    raise RuntimeError("Error calling %s from class %s - %s" %
                                       (data_fn_name, self.__class__.__name__,
                                        ex))
                self.report.log("Trying again (after %d second sleep)" %
                                retry_sleep)
                sleep(retry_sleep)

    def _build_data_fns(self):
        """Build the base data functions specified by FN_TYPE_*
        for each of the types defined in the DataType class.  For example,
        'add_small_data' and 'verify_large_data'.  These
        functions are set to call '*_actual_data' and will pass in
        sane values for label, start and size.  The '*_actual_data'
        methods should be overwritten by a descendant class, and are the
        ones that do the actual work.
        The original 'add_small_data', etc. methods can also be overridden
        if needed, and those overwritten functions will be bound before
        calling any data functions such as 'add_data' or 'remove_data'.
        """
        for fn_type in self.FN_TYPES:
            fn_dict = self._data_fns[fn_type]
            for data_type in DataType:
                self._data_fn_builder(fn_type, data_type.name, fn_dict)
            self._data_fn_builder(fn_type, self.DT_ACTUAL, fn_dict)
        self._override_data_fns()

    def _data_fn_builder(self, fn_type, fn_name, fn_dict):
        """Builds the actual function with a SkipTest exception,
        and changes the name to reflect the pattern.
        """
        data_fn_name = self.data_fn_pattern % (fn_type, fn_name)

        # Build the overridable 'actual' Data Manipulation methods
        if fn_name == self.DT_ACTUAL:
            def data_fn(self, data_label, data_start, data_size, host,
                        *args, **kwargs):
                # default action is to skip the test
                cls_str = ''
                if self._expected_override_name != self.__class__.__name__:
                    cls_str = (' (%s not loaded)' %
                               self._expected_override_name)
                raise SkipTest("Data function '%s' not found in '%s'%s" % (
                    data_fn_name, self.__class__.__name__, cls_str))
        else:
            def data_fn(self, host, *args, **kwargs):
                # call the corresponding 'actual' method
                fns = self._data_fns[fn_type]
                var_dict = self._fn_data[fn_name]
                data_start = var_dict[self.DATA_START]
                data_size = var_dict[self.DATA_SIZE]
                actual_fn_name = self.data_fn_pattern % (
                    fn_type, self.DT_ACTUAL)
                try:
                    fns[actual_fn_name](self, fn_name, data_start, data_size,
                                        host, *args, **kwargs)
                except SkipTest:
                    raise
                except Exception as ex:
                    raise RuntimeError("Error calling %s from class %s: %s" % (
                        data_fn_name, self.__class__.__name__, ex))

        data_fn.__name__ = data_fn.func_name = data_fn_name
        fn_dict[data_fn_name] = data_fn

    def _override_data_fns(self):
        """Bind the override methods to the dict."""
        members = inspect.getmembers(self.__class__,
                                     predicate=inspect.ismethod)
        for fn_type in self.FN_TYPES:
            fns = self._data_fns[fn_type]
            for name, fn in members:
                if name in fns:
                    fns[name] = fn

    #######################
    # Database/User related
    #######################
    def get_valid_database_definitions(self):
        """Return a list of valid database JSON definitions.
        These definitions will be used by tests that create databases.
        Return an empty list if the datastore does not support databases.
        """
        return list()

    def get_valid_user_definitions(self):
        """Return a list of valid user JSON definitions.
         These definitions will be used by tests that create users.
         Return an empty list if the datastore does not support users.
         """
        return list()

    def get_non_existing_database_definition(self):
        """Return a valid JSON definition for a non-existing database.
         This definition will be used by negative database tests.
         The database will not be created by any of the tests.
         Return None if the datastore does not support databases.
         """
        valid_defs = self.get_valid_database_definitions()
        return self._get_non_existing_definition(valid_defs)

    def get_non_existing_user_definition(self):
        """Return a valid JSON definition for a non-existing user.
         This definition will be used by negative user tests.
         The user will not be created by any of the tests.
         Return None if the datastore does not support users.
         """
        valid_defs = self.get_valid_user_definitions()
        return self._get_non_existing_definition(valid_defs)

    def _get_non_existing_definition(self, existing_defs):
        """This will create a unique definition for a non-existing object
        by randomizing one of an existing object.
        """
        if existing_defs:
            non_existing_def = dict(existing_defs[0])
            while non_existing_def in existing_defs:
                non_existing_def = self._randomize_on_name(non_existing_def)
            return non_existing_def

        return None

    def _randomize_on_name(self, definition):
        def_copy = dict(definition)
        def_copy['name'] = ''.join([def_copy['name'], 'rnd'])
        return def_copy

    #############################
    # Configuration Group related
    #############################
    def get_dynamic_group(self):
        """Return a definition of a dynamic configuration group.
        A dynamic group should contain only properties that do not require
        database restart.
        Return an empty dict if the datastore does not have any.
        """
        return dict()

    def get_non_dynamic_group(self):
        """Return a definition of a non-dynamic configuration group.
        A non-dynamic group has to include at least one property that requires
        database restart.
        Return an empty dict if the datastore does not have any.
        """
        return dict()

    def get_invalid_groups(self):
        """Return a list of configuration groups with invalid values.
        An empty list indicates that no 'invalid' tests should be run.
        """
        return []

    def get_configuration_value(self, property_name, host, *args, **kwargs):
        """Use the client to retrieve the value of a given configuration
        property.
        """
        raise SkipTest("Runtime configuration retrieval not implemented in %s"
                       % self.get_class_name())

    ###################
    # Guest Log related
    ###################
    def get_exposed_log_list(self):
        """Return the list of exposed logs for the datastore.  This
        method shouldn't need to be overridden.
        """
        logs = []
        try:
            logs.extend(self.get_exposed_user_log_names())
        except SkipTest:
            pass
        try:
            logs.extend(self.get_exposed_sys_log_names())
        except SkipTest:
            pass

        return logs

    def get_full_log_list(self):
        """Return the full list of all logs for the datastore.  This
        method shouldn't need to be overridden.
        """
        logs = self.get_exposed_log_list()
        try:
            logs.extend(self.get_unexposed_user_log_names())
        except SkipTest:
            pass
        try:
            logs.extend(self.get_unexposed_sys_log_names())
        except SkipTest:
            pass

        return logs

    # Override these guest log methods if needed
    def get_exposed_user_log_names(self):
        """Return the names of the user logs that are visible to all users.
        The first log name will be used for tests.
        """
        raise SkipTest("No exposed user log names defined.")

    def get_unexposed_user_log_names(self):
        """Return the names of the user logs that not visible to all users.
        The first log name will be used for tests.
        """
        raise SkipTest("No unexposed user log names defined.")

    def get_exposed_sys_log_names(self):
        """Return the names of SYS logs that are visible to all users.
        The first log name will be used for tests.
        """
        raise SkipTest("No exposed sys log names defined.")

    def get_unexposed_sys_log_names(self):
        """Return the names of the sys logs that not visible to all users.
        The first log name will be used for tests.
        """
        return ['guest']

    def log_enable_requires_restart(self):
        """Returns whether enabling or disabling a USER log requires a
        restart of the datastore.
        """
        return False

    ################
    # Module related
    ################
    def get_valid_module_type(self):
        """Return a valid module type."""
        return "Ping"

    #################
    # Cluster related
    #################
    def get_cluster_types(self):
        """Returns a list of cluster type lists to use when creating instances.
        The list should be the same size as the number of cluster instances
        that will be created.  If not specified, no types are sent to
        cluster-create.  Cluster grow uses the first type in the list for the
        first instance, and doesn't use anything for the second instance
        (i.e. doesn't pass in anything for 'type').
        An example for this method would be:
            return [['data', 'other_type'], ['third_type']]
        """
        return None
