# Copyright (c) 2013 VMware, 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 __future__ import print_function
from __future__ import division
from __future__ import absolute_import

import json
import os

import tenacity
import time

from oslo_config import cfg
from oslo_log import log as logging
from oslo_messaging import conffixture

from congress.datalog import compile
from congress.datalog import unify
from congress.policy_engines import agnostic

from congress.dse2 import dse_node


LOG = logging.getLogger(__name__)

ROOTDIR = os.path.dirname(__file__)
ETCDIR = os.path.join(ROOTDIR, 'etc')

# single, global variable used to ensure different tests from
#  different subclasses of TestCase all can get a unique ID
#  so that the tests do not interact on oslo-messaging
partition_counter = 0


def make_dsenode_new_partition(node_id,
                               messaging_config=None,
                               node_rpc_endpoints=None):
    """Get new DseNode in it's own new DSE partition."""
    messaging_config = messaging_config or generate_messaging_config()
    node_rpc_endpoints = node_rpc_endpoints or []
    return dse_node.DseNode(messaging_config, node_id, node_rpc_endpoints,
                            partition_id=get_new_partition())


def make_dsenode_same_partition(existing,
                                node_id,
                                messaging_config=None,
                                node_rpc_endpoints=None):
    """Get new DseNode in the same DSE partition as existing (node or part)."""
    partition_id = (existing.partition_id if
                    isinstance(existing, dse_node.DseNode) else existing)

    messaging_config = messaging_config or generate_messaging_config()
    node_rpc_endpoints = node_rpc_endpoints or []
    return dse_node.DseNode(
        messaging_config, node_id, node_rpc_endpoints, partition_id)


def get_new_partition():
    """Create a new partition number, unique within each process."""
    global partition_counter
    old = partition_counter
    partition_counter += 1
    return old


def generate_messaging_config():
    mc_fixture = conffixture.ConfFixture(cfg.CONF)
    mc_fixture.conf.transport_url = 'kombu+memory://'
    messaging_config = mc_fixture.conf
    messaging_config.rpc_response_timeout = 1
    return messaging_config


def etcdir(*p):
    return os.path.join(ETCDIR, *p)


def root_path():
    """Return path to root of source code."""
    x = os.path.realpath(__file__)
    x, y = os.path.split(x)  # drop "helper.py"
    x, y = os.path.split(x)  # drop "tests"
    x, y = os.path.split(x)  # drop "congress"
    return x


def source_path():
    """Return path to root of source code."""
    x = os.path.realpath(__file__)
    x, y = os.path.split(x)  # drop "helper.py"
    x, y = os.path.split(x)  # drop "tests"
    return x


def data_module_path(file):
    """Return path to dataservice module with given FILEname."""
    path = source_path()
    path = os.path.join(path, "datasources")
    path = os.path.join(path, file)
    return path


def policy_module_path():
    """Return path to policy engine module."""
    path = source_path()
    path = os.path.join(path, "policy_engines")
    path = os.path.join(path, "agnostic.py")
    return path


def api_module_path():
    """Return path to api module."""
    path = source_path()
    path = os.path.join(path, "datasources")
    path = os.path.join(path, "test_driver.py")
    return path


def test_path(file=None):
    """Return path to root of top-level tests. Joined with file if provided."""
    path = source_path()
    path = os.path.join(path, "tests")
    if file is not None:
        path = os.path.join(path, file)
    return path


def datasource_config_path():
    """Return path to configuration info for datasources."""
    path = test_path()
    path = os.path.join(path, "datasources.conf")
    return path


def datasource_openstack_args():
    """Return basic args for creating an openstack datasource."""
    return {'username': '',
            'password': '',
            'auth_url': '',
            'tenant_name': '',
            'poll_time': 1}


def pause(factor=1):
    """Timeout so other threads can run."""
    time.sleep(factor * 1)


def datalog_same(actual_code, correct_code, msg=None):
    return datalog_equal(
        actual_code, correct_code, msg=msg,
        equal=lambda x, y: unify.same(x, y) is not None)


def datalog_equal(actual_code, correct_code,
                  msg=None, equal=None, theories=None,
                  output_diff=True):
    """Check equality.

    Check if the strings given by actual_code
    and CORRECT_CODE represent the same datalog.
    """
    def minus(iter1, iter2, invert=False):
        extra = []
        for i1 in iter1:
            found = False
            for i2 in iter2:
                # for asymmetric equality checks
                if invert:
                    test_result = equal(i2, i1)
                else:
                    test_result = equal(i1, i2)
                if test_result:
                    found = True
                    break
            if not found:
                extra.append(i1)
        return extra
    if equal is None:
        equal = lambda x, y: x == y

    LOG.debug("** Checking equality: %s **", msg)
    actual = compile.parse(actual_code, theories=theories)
    correct = compile.parse(correct_code, theories=theories)
    extra = minus(actual, correct)
    # in case EQUAL is asymmetric, always supply actual as the first arg
    #   and set INVERT to true
    missing = minus(correct, actual, invert=True)
    if output_diff:
        output_diffs(extra, missing, msg)
    LOG.debug("** Finished equality: %s **", msg)
    is_equal = len(extra) == 0 and len(missing) == 0
    if not is_equal:
        LOG.debug('datalog_equal failed, extras: %s, missing: %s', extra,
                  missing)
    return is_equal


def db_equal(actual_string, correct_string, output_diff=True):
    """Check if two strings representing data theories are the same."""
    actual = agnostic.string_to_database(actual_string)
    correct = agnostic.string_to_database(correct_string)
    return check_db_diffs(actual, correct, output_diff=output_diff)


def check_db_diffs(actual, correct, output_diff=True):
    extra = actual - correct
    missing = correct - actual
    extra = [e for e in extra if not e[0].startswith("___")]
    missing = [m for m in missing if not m[0].startswith("___")]
    if output_diff:
        output_diffs(extra, missing, actual=actual)
    return len(extra) == 0 and len(missing) == 0


def output_diffs(extra, missing, actual=None):
    if len(extra) > 0:
        print("Extra tuples")
        print(", ".join([str(x) for x in extra]))
    if len(missing) > 0:
        print("Missing tuples")
        print(", ".join([str(x) for x in missing]))
    if len(extra) > 0 or len(missing) > 0:
        print("Resulting database: {}".format(str(actual)))


def str2form(formula_string, theories=None):
    return compile.parse1(formula_string, theories=theories)


def str2pol(policy_string, theories=None):
    return compile.parse(policy_string, theories=theories)


def pol2str(policy):
    return " ".join(str(x) for x in policy)


def form2str(formula):
    return str(formula)


@tenacity.retry(stop=tenacity.stop_after_attempt(1000),
                wait=tenacity.wait_fixed(0.1))
def retry_check_for_last_message(obj):
    if not hasattr(obj, "last_msg"):
        raise AttributeError("Missing 'last_msg' attribute")


@tenacity.retry(stop=tenacity.stop_after_attempt(1000),
                wait=tenacity.wait_fixed(0.1))
def retry_check_for_message_to_arrive(obj):
    if not hasattr(obj.msg, "body"):
        raise AttributeError("Missing 'body' attribute")


@tenacity.retry(stop=tenacity.stop_after_attempt(1000),
                wait=tenacity.wait_fixed(0.1))
def retry_check_for_message_data(obj, data):
    if not hasattr(obj.msg, "body"):
        raise AttributeError("Missing 'body' attribute")
    if obj.get_msg_data() != data:
        raise TestFailureException("Missing expected data in msg")


@tenacity.retry(stop=tenacity.stop_after_attempt(1000),
                wait=tenacity.wait_fixed(0.1))
def retry_check_nonempty_last_policy_change(obj):
    if not hasattr(obj, "last_policy_change"):
        raise AttributeError("Missing 'last_policy_change' attribute")
    if obj.last_policy_change is None:
        raise TestFailureException("last_policy_change == None")
    if len(obj.last_policy_change) == 0:
        raise TestFailureException("last_policy_change == 0")


@tenacity.retry(stop=tenacity.stop_after_attempt(1000),
                wait=tenacity.wait_fixed(0.1))
def retry_check_empty_last_policy_change(obj):
    if not hasattr(obj, "last_policy_change"):
        raise AttributeError("Missing 'last_policy_change' attribute")
    if len(obj.last_policy_change) != 0:
        raise TestFailureException("last_policy_change != 0")


@tenacity.retry(stop=tenacity.stop_after_attempt(1000),
                wait=tenacity.wait_fixed(0.1))
def retry_check_db_equal(policy, query, correct, target=None):
    if not hasattr(policy, "select"):
        raise AttributeError("Missing 'select' attribute")
    if target is None:
        actual = policy.select(query)
    else:
        actual = policy.select(query, target=target)
    if not db_equal(actual, correct, output_diff=False):
        raise TestFailureException(
            "Query {} produces {}, should produce {}".format(
                str(query), str(actual), str(correct)))


@tenacity.retry(stop=tenacity.stop_after_attempt(1000),
                wait=tenacity.wait_fixed(0.1))
def retry_check_number_of_updates(deepsix, value):
    if not hasattr(deepsix, "number_of_updates"):
        raise AttributeError("Missing 'number_of_updates' attribute")
    if deepsix.number_of_updates != value:
        raise TestFailureException("number_of_updates is {}, not {}".format(
            deepsix.number_of_updates, value))


@tenacity.retry(stop=tenacity.stop_after_attempt(1000),
                wait=tenacity.wait_fixed(0.1))
def retry_check_subscriptions(deepsix, subscription_list):
    if not check_subscriptions(deepsix, subscription_list):
        raise TestFailureException(
            "{} does not have subscription list {}".format(
                deepsix.name, str(subscription_list)))


def check_subscriptions(deepsix, subscription_list):
    """Check subscriptions.

    Check that the instance DEEPSIX is subscribed to all of the
    (key, dataindex) pairs in KEY_DATAINDEX_LIST.  Return True if
    all subscriptions exists; otherwise returns False.
    """
    actual = set([(value.key, value.dataindex)
                  for value in deepsix.subdata.values()])
    correct = set(subscription_list)
    missing = correct - actual
    if missing:
        LOG.debug("Missing key/dataindex subscriptions: %s", missing)
    return not missing


@tenacity.retry(stop=tenacity.stop_after_attempt(1000),
                wait=tenacity.wait_fixed(0.1))
def retry_check_subscribers(deepsix, subscriber_list):
    if not check_subscribers(deepsix, subscriber_list):
        raise TestFailureException(
            "{} does not have subscriber list {}".format(
                deepsix.name, str(subscriber_list)))


@tenacity.retry(stop=tenacity.stop_after_attempt(1000),
                wait=tenacity.wait_fixed(0.1))
def retry_check_no_subscribers(deepsix, subscriber_list):
    """Check that deepsix has none of the subscribers in subscriber_list"""
    if check_subscribers(deepsix, subscriber_list, any_=True):
        raise TestFailureException(
            "{} still has some subscribers in list {}".format(
                deepsix.name, str(subscriber_list)))


def check_subscribers(deepsix, subscriber_list, any_=False):
    """Check subscribers.

    Check that the instance DEEPSIX includes subscriptions for all of
    the (name, dataindex) pairs in SUBSCRIBER_LIST.  Return True if
    all subscribers exist; otherwise returns False.

    If any_=True, then return True if ANY subscribers exist in subscriber_list
    """
    actual = set([(name, pubdata.dataindex)
                  for pubdata in deepsix.pubdata.copy().values()
                  for name in pubdata.subscribers])
    correct = set(subscriber_list)
    missing = correct - actual
    if missing:
        LOG.debug("Missing name/dataindex subscribers: %s", missing)
    if any_:
        return (len(missing) < len(actual))
    return not missing


@tenacity.retry(stop=tenacity.stop_after_attempt(20),
                wait=tenacity.wait_fixed(1))
def retry_check_function_return_value(f, expected_value):
    """Check if function f returns expected key."""
    result = f()
    if result != expected_value:
        raise TestFailureException(
            "Expected value '%s' not received.  "
            "Got %s instead." % (expected_value, result))


@tenacity.retry(stop=tenacity.stop_after_attempt(10),
                wait=tenacity.wait_fixed(0.5))
def retry_check_function_return_value_not_eq(f, value):
    """Check if function f does not return expected value."""
    result = f()
    if result == value:
        raise TestFailureException(
            "Actual value '%s' should be different "
            "from '%s'" % (result, value))


@tenacity.retry(stop=tenacity.stop_after_attempt(10),
                wait=tenacity.wait_fixed(0.5))
def retry_til_exception(expected_exception, f):
    """Check if function f does not return expected value."""
    try:
        val = f()
        raise TestFailureException("No exception thrown; received %s" % val)
    except expected_exception:
        return
    except Exception as e:
        raise TestFailureException("Wrong exception thrown: %s" % e)


@tenacity.retry(stop=tenacity.stop_after_attempt(20),
                wait=tenacity.wait_fixed(1))
def retry_check_function_return_value_table(f, expected_values):
    """Check if function f returns expected table."""
    result = f()
    actual = set(tuple(x) for x in result)
    correct = set(tuple(x) for x in expected_values)
    extra = actual - correct
    missing = correct - actual
    if len(extra) > 0 or len(missing) > 0:
        s = "Actual: %s\nExpected: %s\n" % (result, expected_values)
        if len(extra) > 0:
            s += "Extra: %s\n" % extra
        if len(missing) > 0:
            s += "Missing: %s\n" % missing
        raise TestFailureException(s)


class FakeRequest(object):
    def __init__(self, body):
        self.body = json.dumps(body)


class FakeServiceObj(object):
    def __init__(self):
        self.state = {}


class TestFailureException(Exception):
    """Custom exception thrown on test failure

    Facilitates using assertRaises to check for failure on retry tests
    (generic Exception in assertRaises disallowed by pep8 check/gate)
    """
    def __init__(self, *args, **kwargs):
        Exception.__init__(self, *args, **kwargs)
