import imp
import logging
import time
import threading
import requests

from contextlib import contextmanager
from random import randint
from queue import Full

logger = logging.getLogger(__name__)

requests_logger = logging.getLogger('requests')
requests_logger.setLevel(logging.WARNING)
requests.packages.urllib3.disable_warnings()


class AbstractPlugin(object):
    """ Plugin interface
    Parent class for all plugins """

    SECTION = 'DEFAULT'

    @staticmethod
    def get_key():
        """ Get dictionary key for plugin,
        should point to __file__ magic constant """
        raise TypeError("Abstract method needs to be overridden")

    def __init__(self, cfg, name):
        """

        :param name:
        :type cfg: dict
        """
        super(AbstractPlugin, self).__init__()
        self._cleanup_actions = []
        self.log = logging.getLogger(__name__)
        self.cfg = cfg
        self.cfg_section_name = name
        self.interrupted = threading.Event

    def set_option(self, option, value):
        self.cfg[option] = value

    def configure(self):
        """ A stage to read config values and instantiate objects """
        pass

    def prepare_test(self):
        """        Test preparation tasks        """
        pass

    def start_test(self):
        """        Launch test process        """
        pass

    def is_test_finished(self):
        """
        Polling call, if result differs from -1 then test end
        will be triggered
        """
        return -1

    def add_cleanup(self, action):
        """
        :type action: function
        """
        assert callable(action)
        self._cleanup_actions.append(action)

    def cleanup(self):
        for action in reversed(self._cleanup_actions):
            try:
                action()
            except Exception:
                logging.error('Exception occurred during plugin cleanup {}'.format(self.__module__), exc_info=True)

    def end_test(self, retcode):
        """
        Stop processes launched at 'start_test',
        change return code if necessary
        """
        return retcode

    def post_process(self, retcode):
        """ Post-process test data """
        return retcode

    def get_option(self, option_name, default_value=None):
        """ Wrapper to get option from plugins' section """
        return self.cfg.get(option_name, default_value)

    def get_available_options(self):
        """ returns array containing known options for plugin """
        return []

    def get_multiline_option(self, option_name, default_value=None):
        if default_value is not None:
            default = ' '.join(default_value)
        else:
            default = None
        value = self.get_option(option_name, default)
        if value:
            return (' '.join(value.split("\n"))).split(' ')
        else:
            return ()

    def publish(self, key, value):
        """publish value to status"""
        self.log.debug("Publishing status: %s/%s: %s", self.__class__.__name__, key, value)

    def close(self):
        """
        Release allocated resources here.
        Warning: don't do any logic or potentially dangerous operations
        """
        pass


class GunConfigError(Exception):
    pass


class AbstractGun(AbstractPlugin):
    def __init__(self, cfg):
        super(AbstractGun, self).__init__(cfg, 'bfg_gun')
        self.results = None

    @contextmanager
    def measure(self, marker):
        start_time = time.time()
        data_item = {
            "send_ts": start_time,
            "tag": marker,
            "interval_real": None,
            "connect_time": 0,
            "send_time": 0,
            "latency": 0,
            "receive_time": 0,
            "interval_event": 0,
            "size_out": 0,
            "size_in": 0,
            "net_code": 0,
            "proto_code": 200,
        }
        try:
            yield data_item
        except Exception as e:
            logger.warning("%s failed while measuring with %s", marker, e)
            if data_item["proto_code"] == 200:
                data_item["proto_code"] = 500
            if data_item["net_code"] == 0:
                data_item["net_code"] == 1
            raise
        finally:
            if data_item.get("interval_real") is None:
                data_item["interval_real"] = int(
                    (time.time() - start_time) * 1e6)
            try:
                self.results.put(data_item, block=False)
            except Full:
                logger.error("Results full. Data corrupted")

    def setup(self):
        pass

    def shoot(self, missile, marker):
        raise NotImplementedError(
            "Gun should implement 'shoot(self, missile, marker)' method")

    def teardown(self):
        pass

    def get_option(self, key, default_value=None):
        try:
            return super(AbstractGun, self).get_option(key, default_value)
        except KeyError:
            if default_value is not None:
                return default_value
            else:
                raise GunConfigError('Missing key: %s' % key)


class LogGun(AbstractGun):
    SECTION = 'log_gun'

    def __init__(self, cfg):
        super(LogGun, self).__init__(cfg)
        param = self.get_option("param")
        logger.info('Initialized log gun for BFG with param = %s' % param)

    def shoot(self, missile, marker):
        logger.info("Missile: %s\n%s", marker, missile)
        rt = randint(2, 30000) * 1000
        with self.measure(marker) as di:
            di["interval_real"] = rt


class HttpGun(AbstractGun):
    SECTION = 'http_gun'

    def __init__(self, cfg):
        super(HttpGun, self).__init__(cfg)
        self.base_address = cfg["base_address"]

    def shoot(self, missile, marker):
        logger.debug("Missile: %s\n%s", marker, missile)
        logger.debug("Sending request: %s", self.base_address + missile)
        with self.measure(marker) as di:
            try:
                r = requests.get(self.base_address + missile, verify=False)
                di["proto_code"] = r.status_code
            except requests.ConnectionError:
                logger.debug("Connection error", exc_info=True)
                di["net_code"] = 1
                di["proto_code"] = 500


class SqlGun(AbstractGun):
    SECTION = 'sql_gun'

    def __init__(self):
        super(SqlGun, self).__init__()

        from sqlalchemy import create_engine
        from sqlalchemy import exc
        self.exc = exc

        self.engine = create_engine(self.get_option("db"))

    def shoot(self, missile, marker):
        logger.debug("Missile: %s\n%s", marker, missile)
        with self.measure(marker) as di:
            errno = 0
            proto_code = 200
            try:
                cursor = self.engine.execute(missile.replace('%', '%%'))
                cursor.fetchall()
                cursor.close()
            except self.exc.TimeoutError as e:
                logger.debug("Timeout: %s", e)
                errno = 110
            except self.exc.ResourceClosedError as e:
                logger.debug(e)
            except self.exc.SQLAlchemyError as e:
                proto_code = 500
                logger.debug(e.orig.args)
            except self.exc.SAWarning as e:
                proto_code = 400
                logger.debug(e)
            except Exception as e:
                proto_code = 500
                logger.debug(e)
            di["proto_code"] = proto_code
            di["net_code"] = errno


class CustomGun(AbstractGun):
    """
    This gun is deprecated! Use UltimateGun
    """
    SECTION = 'custom_gun'

    def __init__(self, cfg):
        super(CustomGun, self).__init__(cfg)
        logger.warning("Custom gun is deprecated. Use Ultimate gun instead")
        module_path = cfg["module_path"].split()
        module_name = cfg["module_name"]
        fp, pathname, description = imp.find_module(module_name, module_path)
        try:
            self.module = imp.load_module(
                module_name, fp, pathname, description)
        finally:
            if fp:
                fp.close()

    def shoot(self, missile, marker):
        try:
            self.module.shoot(missile, marker, self.measure)
        except Exception as e:
            logger.warning("CustomGun %s failed with %s", marker, e)

    def setup(self):
        if hasattr(self.module, 'init'):
            self.module.init(self)


class ScenarioGun(AbstractGun):
    """
    This gun is deprecated! Use UltimateGun
    """
    SECTION = 'scenario_gun'

    def __init__(self, cfg):
        super(ScenarioGun, self).__init__(cfg)
        logger.warning("Scenario gun is deprecated. Use Ultimate gun instead")
        module_path = cfg["module_path"]
        if module_path:
            module_path = module_path.split()
        else:
            module_path = None
        module_name = cfg["module_name"]
        fp, pathname, description = imp.find_module(module_name, module_path)
        try:
            self.module = imp.load_module(
                module_name, fp, pathname, description)
        finally:
            if fp:
                fp.close()
        self.scenarios = self.module.SCENARIOS

    def shoot(self, missile, marker):
        marker = marker.rsplit("#", 1)[0]  # support enum_ammo
        if not marker:
            marker = "default"
        scenario = self.scenarios.get(marker, None)
        if scenario:
            try:
                scenario(missile, marker, self.measure)
            except Exception as e:
                logger.warning("Scenario %s failed with %s", marker, e)
        else:
            logger.warning("Scenario not found: %s", marker)

    def setup(self):
        if hasattr(self.module, 'init'):
            self.module.init(self)


class UltimateGun(AbstractGun):
    SECTION = "ultimate_gun"

    def __init__(self, cfg):
        super(UltimateGun, self).__init__(cfg)
        class_name = self.get_option("class_name")
        module_path = self.get_option("module_path")
        if module_path:
            module_path = module_path.split()
        else:
            module_path = None
        module_name = self.get_option("module_name")
        self.init_param = self.get_option("init_param")
        fp, pathname, description = imp.find_module(module_name, module_path)
        #
        # Dirty Hack
        #
        # we will add current unix timestamp to the name of a module each time
        # it is imported to be sure Python won't be able to cache it
        #
        try:
            self.module = imp.load_module(
                "%s_%d" % (module_name, time.time()), fp, pathname, description)
        finally:
            if fp:
                fp.close()
        test_class = getattr(self.module, class_name, None)
        if not isinstance(test_class, type):
            raise NotImplementedError(
                "Class definition for '%s' was not found in '%s' module" %
                (class_name, module_name))
        self.load_test = test_class(self)

    def setup(self):
        if callable(getattr(self.load_test, "setup", None)):
            self.load_test.setup(self.init_param)

    def teardown(self):
        if callable(getattr(self.load_test, "teardown", None)):
            self.load_test.teardown()

    def shoot(self, missile, marker):
        marker = marker.rsplit("#", 1)[0]  # support enum_ammo
        if not marker:
            marker = "default"
        scenario = getattr(self.load_test, marker, None)
        if callable(scenario):
            try:
                scenario(missile)
            except Exception as e:
                logger.warning(
                    "Scenario %s failed with %s",
                    marker, e, exc_info=True)
        else:
            logger.warning("Scenario not found: %s", marker)
