#  Copyright 2020 Modelling, Simulation and Design Lab (MSDL) at
#  McGill University and the University of Antwerp (http://msdl.cs.mcgill.ca/)
#
#  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.

"""Set of PythonPDEVS I/O blocks for user interaction.

The classes in this file will allow for logging values
and messages to files for analysis and tracing.

Execute this file to test the :class:`Sound` block.
You should hear a melody being played on the piano.
If this is not the case, something is wrong with the
audio on your system. See the block itself for more
info.

Note:
    In order to obtain the ``syslog`` messages, a
    ``syslog`` server needs to be setup. `You can write
    your own server <https://gist.github.com/marcelom/4218010>`_
    or you can install one on your system.
    `PySysLogQt <https://github.com/RandyParedis/PySysLogQt>`_
    is a tiny, graphical server that was made by the author
    for this specific purpose. It accepts both
    `RFC 5424`-formatted messages as well as the default Python
    format.

Warning:
    In order for the :class:`Logger` block and all its
    subclasses to work, the :meth:`setLogger` function
    needs to be called.
"""

from pypdevs.DEVS import AtomicDEVS
from pypdevs import logger
from pypdevs.infinity import INFINITY


def setLogger(loc, address=('localhost', 1024), loglevel=0, rfc5424=True):
    """Sets the logger to a syslog logger that might follow RFC 5424.

    Note:
        The :meth:`pypdevs.logger.setLogger` creates a logger that's based
        on the standard implementation of the ``SysLogHandler``. Alas, this
        handler does not follow the full `syslog` specification as described
        in `RFC 5424 <https://tools.ietf.org/html/rfc5424>`_ and therefore
        `Python(P)DEVS` doesn't either. This function replaces
        :meth:`pypdevs.logger.setLogger` and provides the correct `RFC`.

    Args:
        loc (any):          Location of the server, to prepend to every
                            logged message.
        address (tuple):    The address of the syslog server in the form of
                            (ip-address, port). Defaults to ``('localhost', 1024)``.
        loglevel (numeric): Minimal log level to support. All messages with a
                            lower level will be discarded. Should be one from
                            in the above list. Defaults to 0.
        rfc5424 (bool):     Whether or not to follow ``RFC 5424`` as a logging
                            format. Defaults to ``True``. Note that this requires
                            `rfc5424-logging-handler` to be installed.
    """
    # Create the default logger
    import logging
    if rfc5424:
        if loglevel is None or logger.logger is not None:
            return
        from rfc5424logging import Rfc5424SysLogHandler
        handler = Rfc5424SysLogHandler(address, facility=19)
        local_logger = logging.getLogger('PyPDEVS-logging')
        local_logger.addHandler(handler)
        local_logger.setLevel(loglevel)
        logger.location = loc
        logger.logger = local_logger
    else:
        logger.setLogger(loc, address, loglevel)
        thelogger = logger.logger
        logging.addLevelName(25, "NOTICE")
        logging.addLevelName(60, "ALERT")
        logging.addLevelName(70, "EMERG")

        # Set all missing functions
        def log(lvl, msg, *args, **kwargs):
            if thelogger.isEnabledFor(lvl):
                thelogger._log(lvl, msg, args, **kwargs)

        thelogger.notice = lambda msg, *args, **kwargs: log(25, msg, *args, **kwargs)
        thelogger.alert = lambda msg, *args, **kwargs: log(60, msg, *args, **kwargs)
        thelogger.emerg = lambda msg, *args, **kwargs: log(70, msg, *args, **kwargs)


class Logger(AtomicDEVS):
    """Generic ``syslog``-logger.

    Before this class can be used, :meth:`setLogger` needs to be called.

    .. image:: ../img/io/logger.svg
		:align: center

    Args:
        name (str):     The name of the block.
        level (str):    Logging level, in string format.
                        This is a function that is called on the
                        logger object.

    Input Ports:
        input (str):    The message to be logged.
    """
    def __init__(self, name: str, level: str="info"):
        super(Logger, self).__init__(name)
        self.level = level

        self.input = self.addInPort("input")

    def extTransition(self, inputs):
        if self.input in inputs:
            logger.log(self.level, str(inputs[self.input]), logger.logger)


class Emergency(Logger):
    """Logs emergency messages.

    Before this class can be used, :meth:`setLogger` needs to be called.

    .. image:: ../img/io/emergency.svg
		:align: center

    Args:
        name (str): The name of the block.

    Input Ports:
        input (str):    The message to be logged.
    """
    def __init__(self, name: str):
        super(Emergency, self).__init__(name, "emerg")


class Alert(Logger):
    """Logs alert messages.

    Before this class can be used, :meth:`setLogger` needs to be called.

    .. image:: ../img/io/alert.svg
		:align: center

    Args:
        name (str): The name of the block.

    Input Ports:
        input (str):    The message to be logged.
    """
    def __init__(self, name: str):
        super(Alert, self).__init__(name, "alert")


class Critical(Logger):
    """Logs critical messages.

    Before this class can be used, :meth:`setLogger` needs to be called.

    .. image:: ../img/io/critical.svg
		:align: center

    Args:
        name (str): The name of the block.

    Input Ports:
        input (str):    The message to be logged.
    """
    def __init__(self, name: str):
        super(Critical, self).__init__(name, "critical")


class Error(Logger):
    """Logs error messages.

    Before this class can be used, :meth:`setLogger` needs to be called.

    .. image:: ../img/io/error.svg
		:align: center

    Args:
        name (str): The name of the block.

    Input Ports:
        input (str):    The message to be logged.
    """
    def __init__(self, name: str):
        super(Error, self).__init__(name, "error")


class Warn(Logger):
    """Logs warning messages.

    Before this class can be used, :meth:`setLogger` needs to be called.

    .. image:: ../img/io/warn.svg
		:align: center

    Args:
        name (str): The name of the block.

    Input Ports:
        input (str):    The message to be logged.
    """
    def __init__(self, name: str):
        super(Warn, self).__init__(name, "warn")


class Notice(Logger):
    """Logs notice messages.

    Before this class can be used, :meth:`setLogger` needs to be called.

    .. image:: ../img/io/notice.svg
		:align: center

    Args:
        name (str): The name of the block.

    Input Ports:
        input (str):    The message to be logged.
    """
    def __init__(self, name: str):
        super(Notice, self).__init__(name, "notice")


class Info(Logger):
    """Logs informational messages.

    Before this class can be used, :meth:`setLogger` needs to be called.

    .. image:: ../img/io/info.svg
		:align: center

    Args:
        name (str): The name of the block.

    Input Ports:
        input (str):    The message to be logged.
    """
    def __init__(self, name: str):
        super(Info, self).__init__(name, "info")


class Debug(Logger):
    """Logs debug messages.

    Before this class can be used, :meth:`setLogger` needs to be called.

    .. image:: ../img/io/debug.svg
		:align: center

    Args:
        name (str): The name of the block.

    Input Ports:
        input (str):    The message to be logged.
    """
    def __init__(self, name: str):
        super(Debug, self).__init__(name, "debug")


class FileWriter(AtomicDEVS):
    """Writes data to a file.

    .. image:: ../img/io/file-writer.svg
		:align: center

    Args:
        blockname (str):    The name of the block.
        filename (str):     The name of the file to write to.
        binary (bool):      Whether or not to open the file in binary mode.
                            Defaults to ``False``.
        append (bool):      Whether or not to append instead of overwriting.
                            Note that this requires the file to already exist.
                            Defaults to ``True``.

    Input Ports:
        input (str/bytes):  When in binary mode, it expects a ``bytes``-object,
                            otherwise a ``str``. This is the data that needs to be
                            written to the file.
    """
    def __init__(self, blockname: str, filename: str, binary: bool=False, append: bool=True):
        super(FileWriter, self).__init__(blockname)
        self.filename = filename
        self.binary = binary
        self.append = append

        self.input = self.addInPort("input")

    def extTransition(self, inputs):
        if self.input in inputs:
            mode = 'w'
            if self.append:
                mode = 'a'
            if self.binary:
                mode += 'b'
            with open(self.filename, mode) as file:
                file.write(inputs[self.input])
        return self.state


class FileReader(AtomicDEVS):
    """Reads contents of a file.

    Every time a filename arrives at the block, its contents
    will be outputted in the same time instance.

    .. image:: ../img/io/file-reader.svg
		:align: center

    Args:
        name (str):             The name of the block.
        binary (bool):          Whether or not to read the file in binary mode.
                                Defaults to ``False``.

    State:
        contents (str):         The contents of the just-read file or ``None``
                                if no file has been read.

    Input Ports:
        filename (str):         The name of the file to read.

    Output Ports:
        contents (str/bytes):   The contents of the file.
    """
    def __init__(self, name: str, binary: bool=False):
        super(FileReader, self).__init__(name)
        self.state = {
            "contents": None
        }
        self.binary = binary

        self.filename = self.addInPort("filename")
        self.contents = self.addOutPort("contents")

    def timeAdvance(self):
        if self.state["contents"] is not None:
            return 0
        return INFINITY

    def intTransition(self):
        self.state["contents"] = None
        return self.state

    def extTransition(self, inputs):
        if self.filename in inputs:
            mode = 'r'
            if self.binary:
                mode += 'b'
            with open(inputs[self.filename], mode) as file:
                self.state["contents"] = file.read()
        return self.state

    def outputFnc(self):
        if self.state["contents"] is not None:
            return { self.contents: self.state["contents"] }
        return {}


class Listener(AtomicDEVS):
    """Listens to an extrenal source.

    The provided function is executed every ``dt`` time and its
    return value is outputted on the `polled` port. Alternatively,
    every time a message arrives on the `dt` port, the ``dt``
    value will change.

    Note:
        The block will poll at the beginning of the simulation.

    .. image:: ../img/io/listener.svg
		:align: center

    Args:
        dt (numeric):       The delay between messages. Cannot be negative
                            or zero. Defaults to ``INFINITY``.
        fp (def):           The polling function to be executed. Takes at least the
                            current time as first input. When returning ``None``,
                            the :class:`Listener` will not fire an output event.
                            Defaults to a `do nothing` function: ``lambda x: None``.
        *args:              Additional arguments used to call the function.

    State:
        dt (numeric):       The time between polls. Cannot be negative or zero.
        elapsed (numeric):  The currently elapsed time.
        value (any):        The return value.

    Input Ports:
        dt (any):           A new ``dt`` value.

    Output Ports:
        listen (any):       The polled result.
    """

    def __init__(self, name: str, dt = INFINITY, fp = lambda t: None, *args):
        assert dt > 0
        super(Listener, self).__init__(name)
        self.state = {
            "dt": dt,
            "elapsed": dt,
            "time": 0.0
        }
        self.fp = fp
        self.args = args

        self.dt = self.addInPort("dt")
        self.listen = self.addOutPort("listen")

    def timeAdvance(self):
        return max(0.0, self.state["dt"] - self.state["elapsed"])

    def outputFnc(self):
        return { self.listen: self.fp(self.state["time"], *self.args) }

    def intTransition(self):
        self.state["elapsed"] = 0.0
        self.state["time"] += self.timeAdvance()
        return self.state

    def extTransition(self, inputs):
        self.state["elapsed"] += self.elapsed
        if self.dt in inputs:
            dt = inputs[self.dt]
            if 0 < dt:
                self.state["dt"] = dt
                self.state["time"] += dt - self.elapsed - 1
        return self.state



# In a try/catch block to allow importing this module even if no
# sounds can/should be played.
try:
    import simpleaudio
    from pydub import AudioSegment
    class Sound(AtomicDEVS):
        """Plays a sound whenever the input is evaluated to be ``True``.

        Can be used for an auditory response on certain events.

        See Also:
            - https://github.com/jiaaro/pydub
            - https://pypi.org/project/simpleaudio/
            - https://github.com/jiaaro/pydub/issues/160

        Note:
            This block is ignored when ``pydub`` or ``simpleaudio`` cannot be imported.

            ``pydub`` will be used to read from as many sound formats as possible (as
            long as it's `supported by ffmpeg <http://www.ffmpeg.org/general.html#File-Formats>`_)
            and ``simpleaudio`` will allow for the sounds to be played asynchronously.

        .. image:: ../img/io/sound.svg
		    :align: center

        Args:
            name (str):         The name of the block.
            soundfile (str):    The filename of the sound to play.
            condition (def):    A function that takes the input as argument
                                and outputs ``True`` if a sound must be played.
                                Defaults to `always`.
            start (int):        When to start the sound, in milliseconds.
                                Defaults to 0 (= beginning of the sound).
            end (int):          When to end the sound, in milliseconds.
                                Defaults to ``None`` (= end of the sound).

        Input Ports:
            input (any):        The item that is checked against the ``condition``
                                before the sound is played.
        """
        def __init__(self, name: str, soundfile: str, condition=lambda x: True, start: int=0, end: int=None):
            super(Sound, self).__init__(name)
            self.sound = soundfile
            self.cond = condition

            self.start = start
            self.end = end

            self.input = self.addInPort("input")

        def play(self):
            """Plays the sound using simpleaudio."""
            ext = self.sound.split(".")[-1]
            sound = AudioSegment.from_file(self.sound, ext)[self.start:self.end]
            playback = simpleaudio.play_buffer(
                sound.raw_data,
                num_channels=sound.channels,
                bytes_per_sample=sound.sample_width,
                sample_rate=sound.frame_rate
            )

        def extTransition(self, inputs):
            if self.input in inputs and self.cond(inputs[self.input]):
                self.play()

except ImportError as e:
    import warnings
    warnings.warn("Could not import Sound block, because '%s' is not installed." % e.name)

if __name__ == "__main__":
    if 'Sound' in dir():
        from pypdevs.simulator import Simulator
        from pypdevs.DEVS import CoupledDEVS
        from pypdevsbbl.generic.generators import TableGenerator
        from pypdevsbbl.extra import fileutils

        sfile = fileutils.test("sound.mp3")

        class Model(CoupledDEVS):
            def __init__(self):
                super(Model, self).__init__("Model")

                self.gen = self.addSubModel(TableGenerator("Generator", [(0, "play")]))
                self.sound = self.addSubModel(Sound("Sound 1", sfile))

                self.connectPorts(self.gen.output, self.sound.input)


        model = Model()
        sim = Simulator(model)
        sim.setClassicDEVS()
        sim.simulate()
    else:
        print("The Sound block was not found.", file=sys.stderr)

