import sys
import time
import signal
import logging
import inspect
import traceback
import multiprocessing
if sys.version_info.major > 2:
    import builtins
else:
    import __builtin__ as builtins
try:
    from setproctitle import setproctitle
except ImportError:
    def setproctitle(*args):
        pass

from ezca import __version__ as ezca__version__
from ezca import Ezca, EzcaError, EzcaConnectError

from .db import WORKER_STATUS
from .log import StateLogger
from .shmem import SharedMemEnum, SharedMemString
from .const import USERMSG_STRING_LENGTH, USERMSG_COUNT, STATE_NAME_LENGTH, SPM_DIFF_COUNT
from .manager import Node, NodeManager

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

WORKER_CMD = ('MAIN', 'RUN', 'NOOP')

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

class WorkerLogger(logging.LoggerAdapter):
    def __init__(self, logger):
        super(WorkerLogger, self).__init__(logger, {})

    def process(self, msg, kwargs):
        msg = 'W: %s' % msg
        return (msg, kwargs)

############################################################
# The worker is what executes all user code.  It is run in a separate
# process so that the main process can terminate it as needed, given
# that the user code may take arbitrarily long to execute.

class Worker(multiprocessing.Process):
    """Guardian daemon worker subprocess.

    """
    def __init__(self, system, logger, snapshot_file=None):
        super(Worker, self).__init__()

        self._system = system
        self._log = WorkerLogger(logger)
        self._statelogger = StateLogger(logger)

        self._userlog_message = ''
        self._usernotify_messages = []

        # shared memory objects
        self._shmem = {}
        self._shmem['CMD'] = SharedMemEnum(WORKER_CMD)
        self._shmem['STATE'] = SharedMemString(size=STATE_NAME_LENGTH)
        self._shmem['EXECSTART'] = multiprocessing.Value('d', lock=True)
        self._shmem['EXECSTOP'] = multiprocessing.Value('d', lock=True)
        self._shmem['STATUS'] = SharedMemEnum(WORKER_STATUS)
        self._shmem['LOGLEVEL'] = SharedMemEnum(('INFO', 'DEBUG', 'LOOP'))
        self._shmem['JUMP'] = SharedMemString(size=STATE_NAME_LENGTH)
        for i in range(USERMSG_COUNT):
            self._shmem['USERMSG'+str(i)] = SharedMemString(size=USERMSG_STRING_LENGTH)
        self._shmem['NUSERMSGS'] = multiprocessing.Value('H', lock=True)
        self._shmem['PV_TOTAL'] = multiprocessing.Value('H', lock=True)
        self._shmem['SPM_TOTAL'] = multiprocessing.Value('H', lock=True)
        self._shmem['SPM_CHANGED'] = multiprocessing.Value('H', lock=True)
        for i in range(SPM_DIFF_COUNT):
            self._shmem['SPM_DIFF'+str(i)] = SharedMemString(size=STATE_NAME_LENGTH)
            self._shmem['SPM_DIFF'+str(i)+'_S'] = SharedMemString(size=USERMSG_STRING_LENGTH)
            self._shmem['SPM_DIFF'+str(i)+'_C'] = SharedMemString(size=USERMSG_STRING_LENGTH)
            self._shmem['SPM_DIFF'+str(i)+'_D'] = SharedMemString(size=USERMSG_STRING_LENGTH)
        self._shmem['SUBNODES_TOTAL'] = multiprocessing.Value('H', lock=True)
        self._shmem['SUBNODES_NOT_OK'] = multiprocessing.Value('H', lock=True)

        # load request event
        self.load_request = multiprocessing.Event()

        # PV snapshots
        self.snapshot_request = multiprocessing.Event()
        self.snapshot_file = snapshot_file

        # run lock. it uses an reentrant lock (RLock) by default, but
        # we don't want anyone to be able to claim the lock more then
        # once, so we pass it a regular Lock object instead.
        self._run_lock = multiprocessing.Condition(multiprocessing.Lock())

        # ready event, used to notify daemon that worker is ready to
        # start processing.
        self._ready_event = multiprocessing.Event()

        # make this process a daemon, so it's necessarily killed when
        # the parent process exits
        self.daemon = True

        self._log.info("initialized")

    ########################################
    # shared memory access

    def __getitem__(self, shmem):
        return self._shmem[shmem].value

    def __setitem__(self, shmem, value):
        self._shmem[shmem].value = value

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

    def command(self, state, cmd):
        """Set command for worker."""
        self._log.log(5, "command received: %s %s" % (state, cmd))
        self['STATE'] = state
        self['CMD'] = cmd
        self._run_lock.notify()

    def acquire(self):
        """Acquire the worker run lock (non-blocking)."""
        return self._run_lock.acquire(False)

    def release(self):
        """Release the worker run lock."""
        self._run_lock.release()

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

    # user notify and log functions
    def _userlog(self, message=''):
        if message == self._userlog_message:
            return
        self._statelogger.warning(message)
        self._userlog_message = message

    def _usernotify(self, message=None):
        # if no message, clear the message cache
        if not message:
            if self._usernotify_messages:
                self._statelogger.warning('USERMSG cleared')
            self._usernotify_messages = []
        else:
            # append message to cache if it's not already there
            if message not in self._usernotify_messages:
                self._usernotify_messages.append(message)
            if message not in self._usernotify_messages_last:
                self._statelogger.warning('USERMSG %d: %s' % (self._usernotify_messages.index(message), message))
            # push message if it's not already in the stack
            if message[:USERMSG_STRING_LENGTH] not in [self['USERMSG'+str(i)].strip('\0') for i in range(USERMSG_COUNT)]:
                ind = len(self._usernotify_messages) - 1
                if ind >= 0 and ind < 10:
                    self['USERMSG'+str(ind)] = self._usernotify_messages[ind]

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

    def spm_diff_set(self, i, chan='', S='', C='', D=''):
        if i >= SPM_DIFF_COUNT:
            return
        sc = 'SPM_DIFF'+str(i)
        self[sc] = chan
        self[sc+'_S'] = S
        self[sc+'_C'] = C
        self[sc+'_D'] = D

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

    def start(self):
        """Start worker process."""
        super(Worker, self).start()
        while not self._ready_event.is_set():
            if self.exitcode is not None:
                raise SystemExit(self.exitcode)

    def run(self):
        setproctitle('guardian-worker {} {}'.format(self._system.name, self._system.path))

        # reset the sigterm handler in the worker back to the default.
        # a custom sigterm handler was installed in main which
        # properly terminates the worker.  the worker needs to reset
        # to the default handler to be able to handle the terminate
        # signals from the main process
        # FIXME: this shouldn't be necessary since daemon=True should
        # take care of this
        signal.signal(signal.SIGTERM, signal.SIG_DFL)

        # FIXME: the following log/state/ezca initialization seems
        # more convoluted than it should be.  it's basically all about
        # recording state in all ezca and state log calls.  we want
        # ezca initialized once for all states, but we want it's log
        # to have the state info.  so we have to create a logger
        # object first, then pass it to ezca and state objects at
        # initialization.  then we update the state in the logger for
        # each new state, so both ezca and state log have the info
        # when they log.
        #
        # A better way might be to actually initialize the EPICS
        # device *outside* of ezca, and initialize the ezca object
        # with the device and log objects during every state.

        # the following variables are made global for state code by
        # adding it to builtins.  this allows user code to access them
        # from anywhere, without having to pass around the objects
        # from the state.

        # initialize ezca EPICS channel access.
        # EPICS devices need to be initialized after forking, since
        # the EPICS device appears to be tied to the process ID.
        # FIXME: can the object somehow be passed from the parent?
        # FIXME: another alternative would be to setup an Ezca proxy
        # server in a separate process that never dies
        builtins.ezca = Ezca(prefix=self._system.ca_prefix,
                             logger=self._statelogger)
        self._log.info("EZCA v{}".format(ezca__version__))
        self._log.info("EZCA CA prefix: {}".format(ezca.prefix))
        if self._system.ca_channels:
            self._log.info("initializing setpoints...")
            ezca.init_setpoints(self._system.ca_channels)

        builtins.notify = self._usernotify
        builtins.log = self._userlog

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

        # the current state object itself
        state = None

        # the condition lock must first be acquired before it can be
        # released by the run_lock.wait() in the begining of the while
        # loop that follows
        self._run_lock.acquire()

        # notify that the worker has fully started
        self._ready_event.set()

        self._log.info("ready")

        ########################################
        ########################################
        while True:

            # clean up USERMSGs and update count.  if there were no
            # notifications this cycle, this should clear all usermsgs
            self['NUSERMSGS'] = len(self._usernotify_messages)
            for i in range(USERMSG_COUNT):
                try:
                    self['USERMSG'+str(i)] = self._usernotify_messages[i]
                except IndexError:
                    self['USERMSG'+str(i)] = ''
            self._usernotify_messages_last = self._usernotify_messages
            self._usernotify_messages = []

            # note command stop time, which happens when we release
            # the lock
            if self['STATUS'] != 'INIT':
                self['EXECSTOP'] = time.time()

            ##############################
            ##############################
            try:
                self._run_lock.wait()
            except KeyboardInterrupt:
                raise SystemExit()
            ##############################
            ##############################

            # note command start time
            self['EXECSTART'] = time.time()

            self._log.logger.setLevel(self['LOGLEVEL'])

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

            # clear all setpoint diff messages on reload
            if self.load_request.is_set():
                self['SPM_CHANGED'] = 0
                for i in range(SPM_DIFF_COUNT):
                    self.spm_diff_set(i)

            ##############################
            if self['CMD'] == 'MAIN':

                ##########
                # initialize the state object
                # we always want a freshly initialized state oject, so
                # there is no chance of anything being leftover from
                # a previous state visit.

                # start by cleaning up and destroying any previous
                # state objects
                # FIXME: it might be best to allow the main process to
                # specifically initiate cleanup as needed.  leaving it
                # until initialization of the next time could leave
                # callbacks or threads in place that might get
                # executed at inopportune times.
                if state:
                    # execute the state destroy method to terminate
                    # any internal threads
                    state.destroy()
                    # FIXME: does setting state to None actually cause
                    # the object to be fully garbage collected?
                    state = None

                if self.load_request.is_set():
                    self._log.info('RELOADING @ %s.main' % self['STATE'])
                    # reload system
                    self._system.load()
                    # initialize setpoints if specified
                    if self._system.ca_channels:
                        self._log.info("re-initializing setpoints...")
                        try:
                            ezca.init_setpoints(self._system.ca_channels)
                        except (EzcaError, EzcaConnectError):
                            self._log.error(traceback.format_exc().splitlines()[-1])
                            self['STATUS'] = 'ERROR'
                            continue
                    # clear the load queue
                    self.load_request.clear()
                    self._log.debug('RELOAD @ %s.main' % self['STATE'])

                self._log.log(5, "%s.enter:", self['STATE'])
                self._statelogger.set_state(self['STATE'])
                self._statelogger.set_func('enter')
                state = self._system[self['STATE']](logfunc=self._userlog)
                self._userlog(' ')
                self._log.log(5, "%s.enter.", self['STATE'])

                ##########

                statefunc = state.main

            ##############################
            elif self['CMD'] == 'RUN':

                ##########
                if self.load_request.is_set():
                    self._log.info('RELOADING @ %s.run' % self['STATE'])
                    # get a list of members in the current state object
                    objects = inspect.getmembers(state)
                    # reload system
                    self._system.load()
                    # re-initialize the state from the newly loaded
                    # system
                    state = self._system[self['STATE']](logfunc=self._userlog)
                    # update the attributes in the new state with the
                    # objects collected from the old state
                    for obj in objects:
                        # skip build-in objects
                        if obj[0][:2] == '__':
                            continue
                        # skip primary methods themselves.
                        if obj[0] in ['main', 'run', 'destroy']:
                            continue
                        try:
                            setattr(state, obj[0], obj[1])
                            self._log_debug("preserving: %s" % obj)
                        except:
                            # pass on any attributes that can't be set
                            pass
                    # initialize setpoints if specified
                    if self._system.ca_channels:
                        self._log.info("re-initializing setpoints...")
                        try:
                            ezca.init_setpoints(self._system.ca_channels)
                        except (EzcaError, EzcaConnectError):
                            self._log.error(traceback.format_exc().splitlines()[-1])
                            self['STATUS'] = 'ERROR'
                            continue
                    # clear the load queue
                    self.load_request.clear()
                    self._log.debug('RELOAD @ %s.run' % self['STATE'])
                ##########

                statefunc = state.run

            ##############################
            elif self['CMD'] == 'NOOP':

                # NOTE: NOOP should NOT change the STATUS, since that
                # can change the state while in pause.  is there a
                # cleaner way?
                statefunc = lambda: None

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

            # NOTE: shmem numbers are initialized to zero.  but that
            # number is probably bogus after initialization. all the
            # following checks are done before setting the worker
            # status to COMMAND, so that the bogus values are only
            # there during INIT.  the validity of these values should
            # therefore only be assumed when the status is *not* INIT.

            self['PV_TOTAL'] = len(ezca.pvs)
            self['SPM_TOTAL'] = len(ezca.setpoints)

            # SPM snapshots
            if self.snapshot_request.is_set():
                self._log.info("PVs:")
                for channel, pv in sorted(ezca.pvs.iteritems()):
                    self._log.info("  %s = %s" % (pv.pvname, pv.char_value))
                self._log.info("SPMs:")
                for channel, sp in sorted(ezca.setpoints.iteritems()):
                    self._log.info("  %s = %s" % (sp[0], sp[1]))
                self._log.info("%d PVs, %d SPMs" % (self['PV_TOTAL'], self['SPM_TOTAL']))
                if self.snapshot_file:
                    ezca.setpoint_snap(self.snapshot_file)
                    self._log.info("SPM snapshot: %s" % (self.snapshot_file))
                self.snapshot_request.clear()

            SPM_index = 0

            # CHECK CONNECTIONS
            dead_channels = ezca.check_connections()
            if dead_channels:
                self._usernotify("CONNECTION ERRORS. see SPM DIFFS for dead channels")
                for chan in dead_channels:
                    self.spm_diff_set(SPM_index, chan, '', 'DEAD', '')
                    SPM_index += 1

            # CHECK SETPOINTS
            if self._system.ca_monitor:
                changed = ezca.check_setpoints()
                if changed:
                    if self._system.ca_monitor_notify:
                        self._usernotify("SETPOINT CHANGES. see SPM DIFFS for differences")
                    for change in changed:
                        if change[0] in dead_channels:
                            continue
                        self.spm_diff_set(SPM_index, *change)
                        SPM_index += 1

            # clear remaining diffs
            if SPM_index < SPM_DIFF_COUNT:
                for i in range(SPM_index, SPM_DIFF_COUNT):
                    self.spm_diff_set(i)

            # note total number of differences
            self['SPM_CHANGED'] = SPM_index

            # if there were any dead channels don't proceed with code
            # execution
            if dead_channels:
                self['STATUS'] = 'CERROR'
                continue

            # CHECK MANAGER SUBORDINATES
            try:
                subnodes_total = set()
                subnodes_not_ok = set()
                for mngr in self._system.node_managers:
                    # make sure all nodes are initialized (node.init() is idempotent)
                    mngr.init()
                    # count subordinates nodes
                    # FIXME: clean up the interfaces to make this simpler
                    if isinstance(mngr, Node):
                        subnodes_total.add(mngr.name)
                        if not mngr.OK:
                            subnodes_not_ok.add(mngr.name)
                    elif isinstance(mngr, NodeManager):
                        subnodes_total |= set(mngr.nodes.keys())
                        subnodes_not_ok |= mngr.not_ok()
                    # FIXME: should we be running check_fault() here?
                self['SUBNODES_TOTAL'] = len(subnodes_total)
                self['SUBNODES_NOT_OK'] = len(subnodes_not_ok)
            except EzcaConnectError as e:
                self._usernotify("EZCA CONNECTION ERROR: %s" % e)
                self['STATUS'] = 'CERROR'
                continue

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

            # FIXME: continue here so that NOOP doesn't change STATUS.
            # is there a cleaner way to do this?
            if self['CMD'] == 'NOOP':
                continue

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

            self['STATUS'] = 'COMMAND'

            cmd = self['CMD'].lower()
            self._log.log(5, "%s.%s:", self['STATE'], cmd)
            self._statelogger.set_func(cmd)

            try:

                ###########
                # USER CODE
                retval = statefunc()
                # USER CODE
                ###########

            except EzcaConnectError as e:
                self._usernotify("EZCA CONNECTION ERROR: %s" % e)
                self['STATUS'] = 'CERROR'
                continue
            except KeyboardInterrupt:
                raise SystemExit
            except:
                # HACK: we should just compare the exception directly,
                # but we don't want to import all of cdsutils here
                # just to do that.  Maybe we should?
                e = traceback.format_exc(0).splitlines()[-1]
                if 'NDSConnectError' in e:
                    self._usernotify("NDS CONNECTION ERROR: %s" % e)
                    self['STATUS'] = 'CERROR'
                else:
                    self._log.error(traceback.format_exc())
                    self._usernotify("USER CODE ERROR (see log)")
                    self['STATUS'] = 'ERROR'
                continue

            self._log.log(5, "%s.%s.", self['STATE'], cmd)

            # string return values are jump state names
            if isinstance(retval, str):
                if retval in self._system:
                    self['JUMP'] = retval
                    self._log.debug("JUMP: %s", retval)
                    self['STATUS'] = 'JUMP'
                else:
                    self._log.error("ERROR: state returned unknown jump state: %s" % retval)
                    self['STATUS'] = 'ERROR'
            elif retval in [None, False]:
                self['STATUS'] = 'CONTINUE'
            elif retval is True:
                self['STATUS'] = 'DONE'
            else:
                self._log.error("state %s returned unexpected type: %s" % (self['STATE'], type(retval)))
                self['STATUS'] = 'ERROR'
