# Component base class for ETL.
#
# Author: Just van den Broecke
#
import os
import sys
from time import time
from .util import Util, ConfigSection
from .packet import FORMAT

log = Util.get_log('component')


class Config:
    """
    Decorator class to tie config values from the .ini file to object instance
    property values. Somewhat like the Python standard @property but with
    the possibility to define default values, typing and making properties required.

    Each property is defined by @Config(type, default, required).
    Basic idea comes from:  https://wiki.python.org/moin/PythonDecoratorLibrary#Cached_Properties
    """

    def __init__(self, ptype=str, default=None, required=False):
        """
        If there are no decorator arguments, the function
        to be decorated is passed to the constructor.
        """
        self.ptype = ptype
        self.default = default
        self.required = required

    def __call__(self, fget, doc=''):
        """
        The __call__ method is not called until the
        decorated function is called. self is returned such that __get__ below is called
        with the Component instance. That allows us to cache the actual property value
        in the Component itself.
        """
        # Save the property name (is the name of the function calling us).
        self.property_name = fget.__name__

        # For Spinx documentation build we need the original function with docstring.
        IS_SPHINX_BUILD = bool(os.getenv('SPHINX_BUILD'))
        if IS_SPHINX_BUILD:
            doc = doc.strip()
            # TODO more detail, example below
            # doc = '``Parameter`` - %s\n\n' % doc
            doc += '* type: %s\n' % str(self.ptype).split("'")[1]
            doc += '* required: %s\n' % self.required
            doc += '* default: %s\n' % self.default

            # if self.value:
            #     doc += '* value: %s\n' % self.value
            # else:
            #     doc += '* required: %s\n' % self.required
            #     doc += '* default: %s\n' % self.default
            #     doc += '* value_range: %s\n' % self.value_range

            fget.__doc__ = '``CONFIG`` %s\n%s' % (fget.__doc__, doc)
            return fget
        else:
            return self

    def __get__(self, comp_inst, owner):
        """ descr.__get__(obj[, type]) -> value """
        if self.property_name not in comp_inst.cfg_vals:
            cfg, name, default_value = comp_inst.cfg, self.property_name, self.default

            # Do type conversion where needed from the string values
            if self.ptype is str:
                value = cfg.get(name, default=default_value)
            elif self.ptype is bool:
                value = cfg.get_bool(name, default=default_value)
            elif self.ptype is list:
                value = cfg.get_list(name, default=default_value)
            elif self.ptype is dict:
                value = cfg.get_dict(name, default=default_value)
            elif self.ptype is int:
                value = cfg.get_int(name, default=default_value)
            elif self.ptype is tuple:
                value = cfg.get_tuple(name, default=default_value)
            else:
                value = cfg.get(name, default=default_value)

            if self.required is True and value is None:
                raise Exception('Config property: %s is required in config for %s' % (name, str(comp_inst)))

            comp_inst.cfg_vals[self.property_name] = value

        return comp_inst.cfg_vals[self.property_name]


class Component:
    """
    Abstract Base class for all Input, Filter and Output Components.

    """

    @Config(ptype=str, default=None, required=False)
    def input_format(self):
        """
        The specific input format if the consumes parameter is a list or the format to be converted to the output_format.
        """
        pass

    @Config(ptype=str, default=None, required=False)
    def output_format(self):
        """
        The specific output format if the produces parameter is a list or the format to which the input format is converted.
        """
        pass

    def __init__(self, configdict, section, consumes=FORMAT.none, produces=FORMAT.none):
        # The raw config values from the cfg file
        self.cfg = ConfigSection(configdict.items(section))

        # The actual typed values as populated within Config Decorator
        self.cfg_vals = dict()
        self.next = None
        self.section = section
        self._max_time = -1
        self._min_time = sys.maxsize
        self._total_time = 0
        self._invoke_count = 0

        # First assume single output provided by derived class
        self._output_format = produces

        # We may have a configured output_format: use that value, when multiple formats it should be in that list
        if self.output_format is not None:
            self._output_format = self.output_format
            if type(produces) is list and self._output_format not in produces:
                raise ValueError('Configured output_format %s not in list: %s' % (self._output_format, str(produces)))
        elif type(produces) is list:
            # No output_format configured and a list: use the first as default
            self._output_format = produces[0]

        # First assume single input provided by derived class
        self._input_format = consumes

        # We may have a configured input_format: use that value, when multiple formats it should be in that list
        if self.input_format is not None:
            self._input_format = self.input_format
            if type(consumes) is list and self._input_format not in consumes:
                raise ValueError('Configured input_format %s not in list: %s' % (self._input_format, str(consumes)))
        elif type(consumes) is list:
            # No input_format configured and a list: use the first as default
            self._input_format = consumes[0]

    def add_next(self, next_component):
        self.next = next_component

        if not self.is_compatible():
            raise ValueError(
                'Incompatible components are linked: %s and %s' % (str(self), str(self.next)))

    # Get our id: currently the [section] name
    def get_id(self):
        return self.section

    # Get last Component in Chain
    def get_last(self):
        last = self
        while last.next:
            last = last.next
            if isinstance(last, list):
                last = last[0]
        return last

    # Check our compatibility with the next Component in the Chain
    def is_compatible(self):

        # Ok, nothing next in Chain
        if self.next is None or self._output_format is FORMAT.none or self.next._input_format == FORMAT.any:
            return True

        # return if our Output is compatible with the next Component's Input
        return self._output_format == self.next._input_format

    def __str__(self):
        return "%s: in=%s out=%s" % (str(self.__class__), self._input_format, self._output_format)

    def process(self, packet):
        # Current processor of packet
        packet.component = self

        start_time = self.timer_start()
        self._invoke_count += 1

        # Do something with the data
        result = self.before_invoke(packet)
        if result is False:
            # Component indicates it does not want the chain to proceed
            self.timer_stop(start_time)
            return packet

        # Do component-specific processing, e.g. read or write or filter
        packet = self.invoke(packet)

        result = self.after_invoke(packet)
        if result is False:
            # Component indicates it does not want the chain to proceed
            self.timer_stop(start_time)
            return packet

        self.timer_stop(start_time)

        # If there is a next component, let it process
        if self.next:
            # Hand-over data (line, doc whatever) to the next component
            packet.format = self._output_format
            packet = self.next.process(packet)

        result = self.after_chain_invoke(packet)
        return packet

    def do_init(self):
        # Some components may do one-time init
        self.init()

        # If there is a next component, let it do its init()
        if self.next:
            self.next.do_init()

    def do_exit(self):
        # Notify all comps that we exit
        self.exit()

        # Simple performance stats in one line (issue #77)
        # Calc average processing time, watch for 0 invoke-case
        avg_time = 0.0
        if self._invoke_count > 0:
            avg_time = self._total_time / self._invoke_count

        log.info("%s invokes=%d time(total, min, max, avg) = %.3f %.3f %.3f %.3f" %
                 (self.__class__.__name__, self._invoke_count,
                  self._total_time, self._min_time, self._max_time,
                  avg_time))

        # If there is a next component, let it do its exit()
        if self.next:
            self.next.do_exit()

    def before_invoke(self, packet):
        """
        Called just before Component invoke.
        """
        return True

    def after_invoke(self, packet):
        """
        Called right after Component invoke.
        """
        return True

    def after_chain_invoke(self, packet):
        """
        Called right after entire Component Chain invoke.
        """
        return True

    def invoke(self, packet):
        """
        Components override for Component-specific behaviour, typically read, filter or write actions.
        """
        return packet

    def init(self):
        """
        Allows derived Components to perform a one-time init.
        """
        pass

    def exit(self):
        """
        Allows derived Components to perform a one-time exit/cleanup.
        """
        pass

    def timer_start(self):
        return time()

    def timer_stop(self, start_time):
        """
        Collect and calculate per-Component performance timing stats.
        :param start_time:
        :return:
        """
        delta_time = time() - start_time

        # Calc timing stats for Component invocation
        self._total_time += delta_time

        if delta_time > self._max_time:
            self._max_time = delta_time

        if delta_time < self._min_time and '%.3f' % delta_time != '0.000':
            self._min_time = delta_time
