# -*- fill-column: 80; -*-

import os
import sys
import imp
import inspect
import networkx
from collections import defaultdict

import builtins
import importlib
from importlib import reload
_builtin__import__ = importlib.__import__

from . import const
from .ligopath import userapps_guardian_paths
from .state import GuardState
from .manager import Node, NodeManager

##################################################

def _check_modpath(path):
    path = path.rstrip('c')
    if os.path.exists(path+'c') and not os.path.exists(path):
        msg = "Module .pyc found without corresponding .py:\n\n"
        msg += '  %sc' % path
        msg += "\n\nRemove defunct module .pyc to continue."
        raise GuardSystemImportError(msg)

##################################################

class GuardSystemError(Exception):
    pass

class GuardSystemImportError(GuardSystemError):
    pass

class GuardSystemLoadError(GuardSystemError):
    pass

class GuardSystem(object):
    """Guardian System description class.

    If not provied, IFO and GUARD_MODULE_PATH will be set from environment.

    """
    def __init__(self,
                 module=None,
                 ifo=None,
                 name=None,
                 ca_prefix=None,
                 guardpath=None,
                 ):
        if not ifo:
            ifo = const.IFO
        self._ifo = ifo
        self._name = name
        self._ca_prefix = ca_prefix

        # resolve the module search path
        if guardpath:
            assert type(guardpath) is list, "guardpath must be a list"
        else:
            guardpath = os.getenv('GUARD_MODULE_PATH')
            if guardpath:
                guardpath = guardpath.split(':')
            else:
                guardpath = userapps_guardian_paths(ifo=self._ifo)

        self._modname = None
        self._modpath = None
        self._usercode = []
        self._module = None

        if module:
            modname = module

            # if the module is specified by path, add its directory to
            # the front of the search path only if it's not already
            # included (this makes sure we don't reprioritize the
            # search path when pointing directly to modules already in
            # the search path.  Also set the module name to be the
            # basename.
            if os.path.exists(module):
                modname = os.path.splitext(os.path.basename(module))[0]
                modpath = os.path.abspath(os.path.dirname(module))
                if modpath not in guardpath:
                    guardpath.insert(0, modpath)

            # find the module, without loading it
            try:
                fp, modpath, description = imp.find_module(modname, guardpath)
                fp.close()
            except ImportError:
                ppath = ''
                for path in guardpath:
                    ppath += '\n' + path
                msg = "Module '%s' not found in any of the following search paths:%s" % (module, ppath)
                raise GuardSystemImportError(msg)

            _check_modpath(modpath)

            self._modname = modname
            self._modpath = os.path.abspath(modpath)

        self._guardpath = guardpath

        if not self._name:
            self._name = self._modname

        # these system parameters will not be reset on reload:
        self._ca_prefitx = ca_prefix

        self._reset()

    ##########

    def _reset(self):
        # resets system to it's pre-load state.

        # (re)initialize the graph object
        self._graph = networkx.DiGraph()

        # reset parameters loaded from module
        self._initial_request = None
        self._nominal_state = None
        self._manager = None
        self._node_managers = {}
        # don't recorgnize ca_prefix changes on reset
        self._ca_monitor = False
        self._ca_monitor_notify = True
        self._ca_channels = []
        self._related_displays = []

        # purge the module object
        if self._module:
            from sys import modules
            del self._module
            self._module = None
            del modules[self._modname]
            # for mod in modules.values():
            #     try:
            #         delattr(mod, self._modname)
            #     except AttributeError:
            #         pass

    def _import(self, name, *args, **kwargs):
        # custom import function that uses the builtin import function to import
        # modules, appending "usercode" modules in usercode attribute.  it also
        # reloads modules in the guardpath if they've already been loaded
        # before.  It then returns the module object itself.
        #
        # FIXME: this is all very hacky because the import internals are
        # inconsistent.  This should all be much easier in python3

        # FIXME: we need to use the builtin import because it appears
        # to be the only thing that handles both submodule import and
        # the "from . import ." form
        module = _builtin__import__(name, *args, **kwargs)

        # ignore "built-in" modules (i.e. without __file__)
        if not hasattr(module, '__file__'):
            return module

        modpath = module.__file__.rstrip('c')

        # only care about modules/packages in the guardpath
        if not self._in_guardpath(modpath):
            return module

        modpaths = set()

        # always add __file__, which will be __init__ in the case of a package
        modpaths.add(modpath)

        # generator for paths of all packages of a module
        def packageup(d):
            d = os.path.split(d)[0]
            p = os.path.join(d, '__init__.py')
            if not os.path.exists(p):
                return
            for dd in packageup(d):
                yield dd
            yield p

        # add all packages to which this module belongs
        for d in packageup(modpath):
            modpaths.add(d)

        # if module has __path__ list then it's a package from which something
        # is being imported
        if hasattr(module, '__path__'):
            for pkgpath in module.__path__:
                # HACK: direct sub-sub-module imports ('import foo.bar.baz') are
                # not detected in any other way in this framework, so we have to
                # specially look for that case and add them manually
                ssm = os.path.join(pkgpath, *name.split('.')[1:])+'.py'
                if os.path.exists(ssm):
                    modpaths.add(ssm)

                # if there's not a fromlist, then just look for modules and
                # packages in the package namespace
                if 'fromlist' in kwargs:
                    fromlist = kwargs['fromlist']
                else:
                    fromlist = [mod for mod in dir(module) if mod[:2] != '__']

                for mod in fromlist:
                    # add submodules
                    modpath = os.path.join(pkgpath, mod+'.py')
                    if os.path.exists(modpath):
                        modpaths.add(modpath)
                        continue
                    # add subpackages
                    modpath = os.path.join(pkgpath, mod, '__init__.py')
                    if os.path.exists(modpath):
                        modpaths.add(modpath)

        # now record and/or reload all found modules
        for path in modpaths:
            # resolve full module file path
            abspath = os.path.abspath(path)
            # check that this isn't a remnant .pyc
            _check_modpath(abspath)
            # add it the to usercode list
            if abspath not in [self._modpath] + self._usercode:
                self._usercode.append(abspath)
            # reload the module if this is a re-import
            if not self.__already_loaded[abspath]:
                reload(module)
                self.__already_loaded[abspath] = True

        return module

    def _in_guardpath(self, modpath):
        # return True if specified path is a subdirectory of a path in the
        # guardpath.
        modpath = os.path.abspath(modpath)
        for gp in self.guardpath:
            if os.path.commonprefix([gp, modpath]) == gp:
                return True
        return False

    def _load_module(self):
        # add guardian paths to the base python search paths so that
        # the module can import other guardian modules or user code
        # with the standard import mechanism
        # FIXME: is there a cleaner way to do this without adding to
        # the overall sys.path?  do we care?
        if self.guardpath not in sys.path:
            sys.path += self.guardpath

        # add IFO and SYSTEM as global variables
        # FIXME: this has to be added to the builtins so that it's
        # globally available in the module we're loading and in
        # anything that it imports.  This isn't clean since it's
        # obviously poluting the global namespace.
        # FIXME: deprecate _*_ variables
        builtins.IFO = self.ifo
        builtins._IFO_ = self.ifo
        builtins.SYSTEM = self.name
        builtins._SYSTEM_ = self.name

        # FIXME: make system module variables available to any other
        # guardian modules it imports

        # FIXME: module reload does not support REMOVAL of states

        # variable to store path to any module loaded from guardpath
        # self._import will populate this as it imports new modules
        # from guardpath
        self._usercode = []

        # reload only once a given load cycle.  The defaultdict is
        # False by default.  When we come to a module we intend to
        # reload, note it in the dict.  That way we don't keep
        # reloading it for every module that imports it.
        self.__already_loaded = defaultdict(bool)

        # a little import chicanery here: override the import builtin
        # during the loading of system module with our custom import
        # runction that reloads any module found in guardpath.  we
        # need to do this so that any user code imported by a system
        # module will also be reloaded.
        # FIXME: there must be a cleaner way to do this.
        builtins.__import__ = self._import
        self._module = self._import(self._modname)
        builtins.__import__ = _builtin__import__

        return self._module

    def add_state(self, name, obj):
        """Add a state to the system.

        """
        if networkx.is_frozen(self._graph):
            raise GuardSystemLoadError("system is frozen.")

        if name in [i[0] for i in const.RESERVED_STATES]:
            raise GuardSystemLoadError("'%s' is a reserved state name." % (name))

        if len(name) > const.STATE_NAME_LENGTH:
            raise GuardSystemLoadError("state name over %d character limit: %s (%d characters)" % (const.STATE_NAME_LENGTH, name, len(name)))

        if not isinstance(obj.goto, bool) and not isinstance(obj.goto, int):
            raise GuardSystemLoadError("state 'goto' attribute must be a bool or int: %s" % (name))

        if not isinstance(obj.request, bool):
            raise GuardSystemLoadError("state 'request' attribute must be a bool: %s" % (name))

        if not isinstance(obj.redirect, bool):
            raise GuardSystemLoadError("state 'redirect' attribute must be a bool: %s" % (name))

        if hasattr(obj, 'index'):
            index = obj.index

            if not isinstance(index, int):
                raise GuardSystemLoadError("state 'index' attribute must be an int: %s" % (name))

            if name == 'INIT' and index != 0:
                raise GuardSystemLoadError("INIT state must be index 0 (assigned 0 if unspecified).")

            if name != 'INIT' and index <= 0:
                raise GuardSystemLoadError("state index must be positive definite: %s" % (name))

            if index in self.indices and name != self.index(index):
                raise GuardSystemLoadError("state index %d already in use by state %s: %s" % (index, self.index(index), name))

        else:
            if name == 'INIT':
                index = 0
            else:
                # assign first free negative integer as index,
                # excluding a set of reserved indices. we use negative
                # numbers to indicate that the index is auto-assigned.
                index = min(self.indices + [i[1] for i in const.RESERVED_STATES]) - 1

        # check that state is valid by attempting to instantiate it
        o = obj()

        # finally, add the state to the graph
        self._graph.add_node(
            name,
            code=obj,
            goto=obj.goto,
            request=obj.request,
            redirect=obj.redirect,
            index=index,
        )

    def add_edge(self, edge, goto=False):
        """Add an edge to the system.

        Edges are tuples of states of the form (SOURCE, DESTINATION).
        An integer edge weight may be specified as the third element
        of the tuple.  If not specified, edges have a weight of 1.

        """
        if networkx.is_frozen(self._graph):
            raise GuardSystemLoadError("system is frozen.")

        data = {}
        data['goto'] = goto
        data['weight'] = 1
        if len(edge) > 2:
            if type(edge[2]) != int:
                raise GuardSystemLoadError("edge weights must be integers: %s" % str(edge))
            data['weight'] = edge[2]

        edge = edge[:2]
        for state in edge:
            if state not in self:
                raise GuardSystemLoadError("unknown edge state: %s" % state)

        # skip if we already have this edge
        if self._graph.has_edge(*edge):
            return
            raise GuardSystemLoadError("system already contains edge: %s" % str(edge))

        # finally, add the edge to the graph
        self._graph.add_edge(*edge, **data)

    def load(self):
        """Import system module and populate system state graph.

        If a module was not defined at initialization an exception
        will be thrown.

        Any previously defined system graph is overwritten before
        loading the module.  The system graph is frozen once the
        module has been fully loaded.

        """
        if not self._modpath:
            raise GuardSystemLoadError("No module specified for this system.")

        self._reset()

        # load the module object
        module = self._load_module()

        # set channel access prefix from module
        # only do this if prefix not specified on system invocation
        if self._ca_prefix is None:
            # use prefix specified in module
            if hasattr(module, 'ca_prefix'):
                self._ca_prefix = module.ca_prefix
            # FIXME: deprecate
            elif hasattr(module, 'prefix'):
                self._ca_prefix = module.prefix

        ###########################
        # add states as graph nodes

        # add default empty INIT state.  this state must always be
        # present, as it's the default start up state
        self.add_state('INIT', GuardState)

        # loop through objects in module and extract all GuardState
        # objects as nodes
        for key, obj in inspect.getmembers(module):

            # register manager objects
            if isinstance(obj, (Node, NodeManager)):
                # if a NodeManager has already been registered with this name,
                # we want to extract it's existing Node objects, since they
                # contain state.
                if key in self._node_managers:
                    mngr = self._node_managers[key]
                    for node in obj:
                        if node.name in mngr:
                            obj.nodes[node.name] = mngr[node]
                self._node_managers[key] = obj
                continue

            # reject everything else that's not a class definition
            if not inspect.isclass(obj):
                continue

            # retrieve all base classes of the object
            bases = inspect.getmro(obj)

            # extract on GuardState object
            if GuardState not in bases:
                continue
            # ignore the GuardState object itself (each object is
            # first in it's own mro list)
            if bases[0] is GuardState:
                continue

            # ignore states starting with underscore ('_') which are
            # used to indicate inheritable base states
            if key[0] == '_':
                continue

            self.add_state(key, obj)

        # set initial request
        if hasattr(module, 'request'):
            if module.request not in self:
                raise GuardSystemLoadError("unknown initial request state: %s" % module.request)
            if not self.is_request(module.request):
                raise GuardSystemLoadError("initial request state must be requestable (request=True): %s" % module.request)
            if module.request == 'INIT':
                raise GuardSystemLoadError("INIT may not be used as the initial request")
            self._initial_request = module.request
        else:
            self._initial_request = None

        # set nominal state
        if hasattr(module, 'nominal'):
            if module.nominal not in self:
                raise GuardSystemLoadError("unknown nominal state: %s" % module.nominal)
            self._nominal_state = module.nominal
        else:
            self._nominal_state = None

        # set manager
        if hasattr(module, 'GRD_MANAGER'):
            self._manager = module.GRD_MANAGER
        else:
            self._manager = None

        # add specified edges
        if hasattr(module, 'edges'):
            # verify states
            for edge in module.edges:
                self.add_edge(edge)

        # add edges from every state to every goto
        for goto in self.goto_states:
            if type(self[goto].goto) is int:
                weight = self[goto].goto
            else:
                weight = const.DEFAULT_GOTO_EDGE_WEIGHT
            for s in self:
                # skip if this is the same state
                if s == goto:
                    continue
                edge = (s, goto, weight)
                self.add_edge(edge, goto=True)

        ###########################
        # other system attributes

        if hasattr(module, 'ca_monitor'):
            if type(module.ca_monitor) is not bool:
                raise GuardSystemLoadError("'ca_monitor' setting must be a bool.")
            self._ca_monitor = module.ca_monitor
        if hasattr(module, 'ca_monitor_notify'):
            if type(module.ca_monitor_notify) is not bool:
                raise GuardSystemLoadError("'ca_monitor_notify' setting must be a bool.")
            self._ca_monitor_notify = module.ca_monitor_notify
        if hasattr(module, 'ca_channels'):
            for channel in module.ca_channels:
                if type(channel) not in [str, tuple]:
                    raise GuardSystemLoadError("'ca_channel' elements must be string or tuple.")
            self._ca_channels = module.ca_channels

        if hasattr(module, 'related_displays'):
            try:
                module.related_displays[0][2]
            except:
                raise GuardSystemLoadError("\"related_display\" attribute must be a list of (label,name,args) tuples.")
            self._related_displays = module.related_displays

        # freeze the graph to prevent modification after creation
        networkx.freeze(self._graph)

        return self

    ##########

    @property
    def guardpath(self):
        """Module search path."""
        return self._guardpath

    @property
    def ifo(self):
        """System IFO."""
        return self._ifo

    @property
    def name(self):
        """System name."""
        return self._name

    @property
    def modname(self):
        """Name of module object."""
        return self._modname

    @property
    def path(self):
        """Primary module path."""
        return self._modpath

    @property
    def usercode(self):
        """List of "usercode" modules loaded from guardpath."""
        return self._usercode

    @property
    def module(self):
        """Loaded module object."""
        return self._module

    @property
    def ca_prefix(self):
        """EPICS channel access prefix."""
        return self._ca_prefix

    @property
    def ca_monitor(self):
        """EPICS setting monitor bool."""
        return self._ca_monitor

    @property
    def ca_monitor_notify(self):
        """EPICS setting monitor notification bool."""
        return self._ca_monitor_notify

    @property
    def ca_channels(self):
        """EPICS setting monitor channel initializations."""
        return self._ca_channels

    @property
    def related_displays(self):
        """User code specified MEDM related displays.

        List of (label, name, args) string tuples.
        """
        return self._related_displays

    @property
    def request(self):
        """User code specified REQUEST state."""
        return self._initial_request

    @property
    def nominal(self):
        """User code specified NOMINAL state."""
        return self._nominal_state

    @property
    def manager(self):
        """User code specified MANAGER."""
        return self._manager

    @property
    def graph(self):
        """System state graph object."""
        return self._graph

    def is_goto(self, state):
        """Return True if state is a goto state."""
        return self._graph.nodes[state]['goto']

    def is_request(self, state):
        """Return True if state is a requestable state."""
        return self._graph.nodes[state]['request']

    def can_redirect(self, state):
        """Return True if state is redirectable."""
        return self._graph.nodes[state]['redirect']


    def states_iter(self, reverse=True, filters=[], init_last=True):
        """Generator of (state, index, requestable) tuple.

        States are ordered by descending index by default.  Set
        'reverse=False' to order in ascending index.

        'filters' is a list of tuples of state data (key, value) pairs
        such that the returned states will have data[key] == value.

        'init_last' sets whether or not the INIT state is appended
        separately at the end of the list, or inserted in index order.

        """
        def filt(filters, data):
            r = True
            for filt in filters:
                r &= data[filt[0]] == filt[1]
            return r
        for state, data in sorted(self._graph.nodes(data=True),
                                  key=lambda out: out[1]['index'],
                                  reverse=reverse):
            if init_last and state == 'INIT':
                continue
            if filt(filters, data):
                yield (state, data['index'], data['request'])
        if init_last and 'INIT' in self._graph:
            state = 'INIT'
            data = self._graph.nodes[state]
            if filt(filters, data):
                yield (state, data['index'], data['request'])

    @property
    def states(self):
        """Ordered list of state names."""
        return [data[0] for data in self.states_iter()]

    @property
    def goto_states(self):
        """Ordered list of goto state names."""
        return [data[0] for data in self.states_iter(filters=[('goto', True)])]

    @property
    def request_states(self):
        """Ordered list of requestable state names."""
        return [data[0] for data in self.states_iter(filters=[('request', True)])]


    @property
    def edges(self):
        """List of graph directed edge tuples."""
        return self._graph.edges()

    ##########

    @property
    def node_managers(self):
        """List of NodeManager objects"""
        return self._node_managers.values()

    ##########

    def __repr__(self):
        return "%s(%r)" % (self.__class__.__name__, self.name)

    def __str__(self):
        return "<%s %r>" % (self.__class__.__name__, self.name)

    def __contains__(self, state):
        """Return True if state in system."""
        return state in self._graph

    def __len__(self):
        """Return number of states in system."""
        return len(self._graph)

    def __iter__(self):
        """Iterator over state names."""
        return iter(self._graph.nodes)

    def _data_for_key(self, key):
        """Return state (key, data) tuple for state.

        If a string is provided, key is the state index.  If a number
        is provided, key is the state name.

        KeyError or TypeError exceptions are raised where appropriate.

        """
        if isinstance(key, str):
            try:
                data = self._graph.nodes[key]
                return data['index'], data
            except KeyError:
                raise KeyError("%s is not a state name" % key)

        elif isinstance(key, int):
            for state, index in self._graph.nodes(data='index'):
                if index == key:
                    return state, self._graph.nodes[state]
            raise KeyError("%s is not a state index" % key)

        else:
            raise TypeError("item must be state name string or index integer.")

    def __getitem__(self, key):
        """Get state object from state name string or index."""
        return self._data_for_key(key)[1]['code']

    ##########

    @property
    def indices(self):
        """List of state indices."""
        return [index for state, index in self._graph.nodes(data='index')]

    def state_index_dict(self):
        """Return a dict of state:index pairs."""
        return {state: index for state, index in self._graph.nodes(data='index')}

    def index_state_dict(self):
        """Return a dict of index:state pairs."""
        return {index: state for state, index in self._graph.nodes(data='index')}

    def index(self, key):
        """Convert state name to index or vice versa.

        If a string is provided it is interpreted as a state name and
        the corresponding state index is returned.  If an number is
        provided it is interpreted as a state index and the
        corresponding state name string is returned.

        """
        return self._data_for_key(key)[0]

    ##########

    def shortest_path(self, source, request):
        """Return shortest path between *source* and *request* as list of state names.

        When multiple paths exist between *source* and *request*, the
        shortest path is the one with the lowest edge weight sum.  By
        default, edges have a weight of one.  Edge weights may be
        positive or negative.  If multiple equal weight path exists,
        one will be chosen arbitrarily.

        If the *source* is in the state graph the path will be
        calculated directly.  If it is not (source == None), traverse
        the graph looking for a goto state closest to the requested
        state, and use that as the source.

        If there is no path between the source and the request, return
        None.

        """
        # check validity of state and request arguments
        if source:
            self[source]
        self[request]

        # if the source in not specified (None), find the goto with
        # the shortest path to the request
        if not source and self.goto_states:
            slen = 0
            sg = None
            for g in self.goto_states:
                if networkx.has_path(self.graph, g, request):
                    ilen = 1./len(networkx.shortest_path(self.graph, g, request))
                    if ilen > slen:
                        sg = g
            if sg:
                source = sg

        if not networkx.has_path(self.graph, source, request):
            return None

        return networkx.shortest_path(self.graph, source, request, weight='weight')
