# SPDX-License-Identifier: GPL-2.0-only
# This file is part of Scapy
# See https://scapy.net/ for more information
# Copyright (C) Philippe Biondi <phil@secdev.org>

import os
import queue
import subprocess
import time
from threading import Lock, Thread

from scapy.automaton import (
    Message,
    ObjectPipe,
    select_objects,
)
from scapy.consts import WINDOWS
from scapy.error import log_runtime, warning
from scapy.config import conf
from scapy.utils import get_temp_file, do_graph

from typing import (
    Any,
    Callable,
    Dict,
    Iterable,
    Optional,
    Set,
    Tuple,
    Union,
    Type,
    TypeVar,
    cast,
)


class PipeEngine(ObjectPipe[str]):
    pipes = {}  # type: Dict[str, Type[Pipe]]

    @classmethod
    def list_pipes(cls):
        # type: () -> None
        for pn, pc in sorted(cls.pipes.items()):
            doc = pc.__doc__ or ""
            if doc:
                doc = doc.splitlines()[0]
            print("%20s: %s" % (pn, doc))

    @classmethod
    def list_pipes_detailed(cls):
        # type: () -> None
        for pn, pc in sorted(cls.pipes.items()):
            if pc.__doc__:
                print("###### %s\n %s" % (pn, pc.__doc__))
            else:
                print("###### %s" % pn)

    def __init__(self, *pipes):
        # type: (*Pipe) -> None
        ObjectPipe.__init__(self, "PipeEngine")
        self.active_pipes = set()  # type: Set[Pipe]
        self.active_sources = set()  # type: Set[Union[Source, PipeEngine]]
        self.active_drains = set()  # type: Set[Pipe]
        self.active_sinks = set()  # type: Set[Pipe]
        self._add_pipes(*pipes)
        self.thread_lock = Lock()
        self.command_lock = Lock()
        self.thread = None  # type: Optional[Thread]

    def __getattr__(self, attr):
        # type: (str) -> Callable[..., Pipe]
        if attr.startswith("spawn_"):
            dname = attr[6:]
            if dname in self.pipes:
                def f(*args, **kargs):
                    # type: (*Any, **Any) -> Pipe
                    k = self.pipes[dname]
                    p = k(*args, **kargs)  # type: Pipe
                    self.add(p)
                    return p
                return f
        raise AttributeError(attr)

    def _read_cmd(self):
        # type: () -> str
        return self.recv()  # type: ignore

    def _write_cmd(self, _cmd):
        # type: (str) -> None
        self.send(_cmd)

    def add_one_pipe(self, pipe):
        # type: (Pipe) -> None
        self.active_pipes.add(pipe)
        if isinstance(pipe, Source):
            self.active_sources.add(pipe)
        if isinstance(pipe, Drain):
            self.active_drains.add(pipe)
        if isinstance(pipe, Sink):
            self.active_sinks.add(pipe)

    def get_pipe_list(self, pipe):
        # type: (Pipe) -> Set[Any]
        def flatten(p,  # type: Any
                    li,  # type: Set[Pipe]
                    ):
            # type: (...) -> None
            li.add(p)
            for q in p.sources | p.sinks | p.high_sources | p.high_sinks:
                if q not in li:
                    flatten(q, li)
        pl = set()  # type: Set[Pipe]
        flatten(pipe, pl)
        return pl

    def _add_pipes(self, *pipes):
        # type: (*Pipe) -> Set[Pipe]
        pl = set()
        for p in pipes:
            pl |= self.get_pipe_list(p)
        pl -= self.active_pipes
        for q in pl:
            self.add_one_pipe(q)
        return pl

    def run(self):
        # type: () -> None
        log_runtime.debug("Pipe engine thread started.")
        try:
            for p in self.active_pipes:
                p.start()
            sources = self.active_sources
            sources.add(self)
            exhausted = set([])  # type: Set[Union[Source, PipeEngine]]
            RUN = True
            STOP_IF_EXHAUSTED = False
            while RUN and (not STOP_IF_EXHAUSTED or len(sources) > 1):
                fds = select_objects(sources, 0.5)
                for fd in fds:
                    if fd is self:
                        cmd = self._read_cmd()
                        if cmd == "X":
                            RUN = False
                            break
                        elif cmd == "B":
                            STOP_IF_EXHAUSTED = True
                        elif cmd == "A":
                            sources = self.active_sources - exhausted
                            sources.add(self)
                        else:
                            warning("Unknown internal pipe engine command: %r."
                                    " Ignoring.", cmd)
                    elif fd in sources:
                        try:
                            fd.deliver()
                        except Exception as e:
                            log_runtime.exception("piping from %s failed: %s",
                                                  fd.name, e)
                        else:
                            if fd.exhausted():
                                exhausted.add(fd)
                                sources.remove(fd)
        except KeyboardInterrupt:
            pass
        finally:
            try:
                for p in self.active_pipes:
                    p.stop()
            finally:
                self.thread_lock.release()
                log_runtime.debug("Pipe engine thread stopped.")

    def start(self):
        # type: () -> None
        if self.thread_lock.acquire(False):
            _t = Thread(target=self.run, name="scapy.pipetool.PipeEngine")
            _t.daemon = True
            _t.start()
            self.thread = _t
        else:
            log_runtime.debug("Pipe engine already running")

    def wait_and_stop(self):
        # type: () -> None
        self.stop(_cmd="B")

    def stop(self, _cmd="X"):
        # type: (str) -> None
        try:
            with self.command_lock:
                if self.thread is not None:
                    self._write_cmd(_cmd)
                    self.thread.join()
                    try:
                        self.thread_lock.release()
                    except Exception:
                        pass
                else:
                    log_runtime.debug("Pipe engine thread not running")
        except KeyboardInterrupt:
            print("Interrupted by user.")

    def add(self, *pipes):
        # type: (*Pipe) -> None
        _pipes = self._add_pipes(*pipes)
        with self.command_lock:
            if self.thread is not None:
                for p in _pipes:
                    p.start()
                self._write_cmd("A")

    def graph(self, **kargs):
        # type: (Any) -> None
        g = ['digraph "pipe" {', "\tnode [shape=rectangle];", ]
        for p in self.active_pipes:
            g.append('\t"%i" [label="%s"];' % (id(p), p.name))
        g.append("")
        g.append("\tedge [color=blue, arrowhead=vee];")
        for p in self.active_pipes:
            for s in p.sinks:
                g.append('\t"%i" -> "%i";' % (id(p), id(s)))
        g.append("")
        g.append("\tedge [color=purple, arrowhead=veevee];")
        for p in self.active_pipes:
            for hs in p.high_sinks:
                g.append('\t"%i" -> "%i";' % (id(p), id(hs)))
        g.append("")
        g.append("\tedge [color=red, arrowhead=diamond];")
        for p in self.active_pipes:
            for ts in p.trigger_sinks:
                g.append('\t"%i" -> "%i";' % (id(p), id(ts)))
        g.append('}')
        graph = "\n".join(g)
        do_graph(graph, **kargs)


class _PipeMeta(type):
    def __new__(cls,
                name,  # type: str
                bases,  # type: Tuple[type, ...]
                dct  # type: Dict[str, Any]
                ):
        # type: (...) -> Type[Pipe]
        c = cast('Type[Pipe]',
                 super(_PipeMeta, cls).__new__(cls, name, bases, dct))
        PipeEngine.pipes[name] = c
        return c


_S = TypeVar("_S", bound="Sink")
_TS = TypeVar("_TS", bound="TriggerSink")


class Pipe(metaclass=_PipeMeta):
    def __init__(self, name=None):
        # type: (Optional[str]) -> None
        self.sources = set()  # type: Set['Pipe']
        self.sinks = set()  # type: Set['Sink']
        self.high_sources = set()  # type: Set['Pipe']
        self.high_sinks = set()  # type: Set['Sink']
        self.trigger_sources = set()  # type: Set['Pipe']
        self.trigger_sinks = set()  # type: Set['TriggerSink']
        if name is None:
            name = "%s" % (self.__class__.__name__)
        self.name = name

    def _send(self, msg):
        # type: (Any) -> None
        for s in self.sinks:
            s.push(msg)

    def _high_send(self, msg):
        # type: (Any) -> None
        for s in self.high_sinks:
            s.high_push(msg)

    def _trigger(self, msg=None):
        # type: (Any) -> None
        for s in self.trigger_sinks:
            s.on_trigger(msg)

    def __gt__(self, other):
        # type: (_S) -> _S
        self.sinks.add(other)
        other.sources.add(self)
        return other

    def __rshift__(self, other):
        # type: (_S) -> _S
        self.high_sinks.add(other)
        other.high_sources.add(self)
        return other

    def __xor__(self, other):
        # type: (_TS) -> _TS
        self.trigger_sinks.add(other)
        other.trigger_sources.add(self)
        return other

    def __hash__(self):
        # type: () -> int
        return object.__hash__(self)

    def __eq__(self, other):
        # type: (Any) -> bool
        return object.__eq__(self, other)

    def __repr__(self):
        # type: () -> str
        ct = conf.color_theme
        s = "%s%s" % (ct.punct("<"), ct.layer_name(self.name))
        if self.sources or self.sinks:
            s += " %s" % ct.punct("[")
            if self.sources:
                s += "%s%s" % (ct.punct(",").join(ct.field_name(s.name) for s in self.sources),  # noqa: E501
                               ct.field_value(">"))
            s += ct.layer_name("#")
            if self.sinks:
                s += "%s%s" % (ct.field_value(">"),
                               ct.punct(",").join(ct.field_name(s.name) for s in self.sinks))  # noqa: E501
            s += ct.punct("]")

        if self.high_sources or self.high_sinks:
            s += " %s" % ct.punct("[")
            if self.high_sources:
                s += "%s%s" % (ct.punct(",").join(ct.field_name(s.name) for s in self.high_sources),  # noqa: E501
                               ct.field_value(">>"))
            s += ct.layer_name("#")
            if self.high_sinks:
                s += "%s%s" % (ct.field_value(">>"),
                               ct.punct(",").join(ct.field_name(s.name) for s in self.high_sinks))  # noqa: E501
            s += ct.punct("]")

        if self.trigger_sources or self.trigger_sinks:
            s += " %s" % ct.punct("[")
            if self.trigger_sources:
                s += "%s%s" % (ct.punct(",").join(ct.field_name(s.name) for s in self.trigger_sources),  # noqa: E501
                               ct.field_value("^"))
            s += ct.layer_name("#")
            if self.trigger_sinks:
                s += "%s%s" % (ct.field_value("^"),
                               ct.punct(",").join(ct.field_name(s.name) for s in self.trigger_sinks))  # noqa: E501
            s += ct.punct("]")

        s += ct.punct(">")
        return s

    def start(self):
        # type: () -> None
        pass

    def stop(self):
        # type: () -> None
        pass


class Source(Pipe, ObjectPipe[Any]):
    def __init__(self, name=None):
        # type: (Optional[str]) -> None
        ObjectPipe.__init__(self, name)
        Pipe.__init__(self, name=name)
        self.is_exhausted = False

    def _read_message(self):
        # type: () -> Message
        return Message()

    def deliver(self):
        # type: () -> None
        msg = self._read_message
        self._send(msg)

    def exhausted(self):
        # type: () -> bool
        return self.is_exhausted


class Drain(Pipe):
    """Repeat messages from low/high entries to (resp.) low/high exits

    .. code::

         +-------+
      >>-|-------|->>
         |       |
       >-|-------|->
         +-------+
    """

    def push(self, msg):
        # type: (Any) -> None
        self._send(msg)

    def high_push(self, msg):
        # type: (Any) -> None
        self._high_send(msg)


class Sink(Pipe):
    """
    Does nothing; interface to extend for custom sinks.

    All sinks have the following constructor parameters:

    :param name: a human-readable name for the element
    :type name: str
    """
    def push(self, msg):
        # type: (Any) -> None
        """
        Called by :py:class:`PipeEngine` when there is a new message for the
        low entry.

        :param msg: The message data
        :returns: None
        :rtype: None
        """
        pass

    def high_push(self, msg):
        # type: (Any) -> None
        """
        Called by :py:class:`PipeEngine` when there is a new message for the
        high entry.

        :param msg: The message data
        :returns: None
        :rtype: None
        """
        pass

    def __lt__(self, other):
        # type: (_S) -> _S
        other.sinks.add(self)
        self.sources.add(other)
        return other

    def __lshift__(self, other):
        # type: (_S) -> _S
        self.high_sources.add(other)
        other.high_sinks.add(self)
        return other

    def __floordiv__(self, other):
        # type: (_S) -> _S
        self >> other
        other >> self
        return other

    def __mod__(self, other):
        # type: (_S) -> _S
        self > other
        other > self
        return other


class TriggerSink(Sink):
    def on_trigger(self, msg):
        # type: (Any) -> None
        pass


class AutoSource(Source):
    def __init__(self, name=None):
        # type: (Optional[str]) -> None
        Source.__init__(self, name=name)

    def _gen_data(self, msg):
        # type: (str) -> None
        ObjectPipe.send(self, (msg, False, False))

    def _gen_high_data(self, msg):
        # type: (str) -> None
        ObjectPipe.send(self, (msg, True, False))

    def _exhaust(self):
        # type: () -> None
        ObjectPipe.send(self, (None, None, True))

    def deliver(self):
        # type: () -> None
        msg, high, exhaust = self.recv()  # type: ignore
        if exhaust:
            pass
        if high:
            self._high_send(msg)
        else:
            self._send(msg)


class ThreadGenSource(AutoSource):
    def __init__(self, name=None):
        # type: (Optional[str]) -> None
        AutoSource.__init__(self, name=name)
        self.RUN = False

    def generate(self):
        # type: () -> None
        pass

    def start(self):
        # type: () -> None
        self.RUN = True
        Thread(target=self.generate,
               name="scapy.pipetool.ThreadGenSource").start()

    def stop(self):
        # type: () -> None
        self.RUN = False


class ConsoleSink(Sink):
    """Print messages on low and high entries to ``stdout``

    .. code::

         +-------+
      >>-|--.    |->>
         | print |
       >-|--'    |->
         +-------+
    """

    def push(self, msg):
        # type: (str) -> None
        print(">" + repr(msg))

    def high_push(self, msg):
        # type: (str) -> None
        print(">>" + repr(msg))


class RawConsoleSink(Sink):
    """Print messages on low and high entries, using os.write

    .. code::

         +-------+
      >>-|--.    |->>
         | write |
       >-|--'    |->
         +-------+

    :param newlines: Include a new-line character after printing each packet.
                     Defaults to True.
    :type newlines: bool
    """

    def __init__(self, name=None, newlines=True):
        # type: (Optional[str], bool) -> None
        Sink.__init__(self, name=name)
        self.newlines = newlines
        self._write_pipe = 1

    def push(self, msg):
        # type: (str) -> None
        if self.newlines:
            msg += "\n"
        os.write(self._write_pipe, msg.encode("utf8"))

    def high_push(self, msg):
        # type: (str) -> None
        if self.newlines:
            msg += "\n"
        os.write(self._write_pipe, msg.encode("utf8"))


class CLIFeeder(AutoSource):
    """Send messages from python command line:

    .. code::

         +--------+
      >>-|        |->>
         | send() |
       >-|   `----|->
         +--------+
    """

    def send(self, msg):
        # type: (str) -> int
        self._gen_data(msg)
        return 1

    def close(self):
        # type: () -> None
        self.is_exhausted = True


class CLIHighFeeder(CLIFeeder):
    """Send messages from python command line to high output:

    .. code::

         +--------+
      >>-|   .----|->>
         | send() |
       >-|        |->
         +--------+
    """

    def send(self, msg):
        # type: (Any) -> int
        self._gen_high_data(msg)
        return 1


class PeriodicSource(ThreadGenSource):
    """Generate messages periodically on low exit:

    .. code::

         +-------+
      >>-|       |->>
         | msg,T |
       >-|  `----|->
         +-------+
    """

    def __init__(self, msg, period, period2=0, name=None):
        # type: (Union[Iterable[Any], Any], int, int, Optional[str]) -> None
        ThreadGenSource.__init__(self, name=name)
        if not isinstance(msg, (list, set, tuple)):
            self.msg = [msg]  # type: Iterable[Any]
        else:
            self.msg = msg
        self.period = period
        self.period2 = period2

    def generate(self):
        # type: () -> None
        while self.RUN:
            empty_gen = True
            for m in self.msg:
                empty_gen = False
                self._gen_data(m)
                time.sleep(self.period)
            if empty_gen:
                self.is_exhausted = True
                self._exhaust()
            time.sleep(self.period2)


class TermSink(Sink):
    """
    Prints messages on the low and high entries, on a separate terminal (xterm
    or cmd).

    .. code::

         +-------+
      >>-|--.    |->>
         | print |
       >-|--'    |->
         +-------+

    :param keepterm: Leave the terminal window open after :py:meth:`~Pipe.stop`
                     is called. Defaults to True.
    :type keepterm: bool
    :param newlines: Include a new-line character after printing each packet.
                     Defaults to True.
    :type newlines: bool
    :param openearly: Automatically starts the terminal when the constructor is
                      called, rather than waiting for :py:meth:`~Pipe.start`.
                      Defaults to True.
    :type openearly: bool
    """

    def __init__(self, name=None, keepterm=True, newlines=True,
                 openearly=True):
        # type: (Optional[str], bool, bool, bool) -> None
        Sink.__init__(self, name=name)
        self.keepterm = keepterm
        self.newlines = newlines
        self.openearly = openearly
        self.opened = False
        if self.openearly:
            self.start()

    if WINDOWS:
        def _start_windows(self):
            # type: () -> None
            if not self.opened:
                self.opened = True
                self.__f = get_temp_file()
                open(self.__f, "a").close()
                self.name = "Scapy" if self.name is None else self.name
                # Start a powershell in a new window and print the PID
                cmd = "$app = Start-Process PowerShell -ArgumentList '-command &{$host.ui.RawUI.WindowTitle=\\\"%s\\\";Get-Content \\\"%s\\\" -wait}' -passthru; echo $app.Id" % (self.name, self.__f.replace("\\", "\\\\"))  # noqa: E501
                proc = subprocess.Popen(
                    [
                        getattr(conf.prog, "powershell"),
                        cmd
                    ],
                    stdout=subprocess.PIPE
                )
                output, _ = proc.communicate()
                # This is the process PID
                self.pid = int(output)
                print("PID: %d" % self.pid)

        def _stop_windows(self):
            # type: () -> None
            if not self.keepterm:
                self.opened = False
                # Recipe to kill process with PID
                # http://code.activestate.com/recipes/347462-terminating-a-subprocess-on-windows/
                import ctypes
                PROCESS_TERMINATE = 1
                handle = ctypes.windll.kernel32.OpenProcess(PROCESS_TERMINATE, False, self.pid)  # noqa: E501
                ctypes.windll.kernel32.TerminateProcess(handle, -1)
                ctypes.windll.kernel32.CloseHandle(handle)
    else:
        def _start_unix(self):
            # type: () -> None
            if not self.opened:
                self.opened = True
                rdesc, self.wdesc = os.pipe()
                os.set_inheritable(rdesc, True)
                cmd = ["xterm"]
                if self.name is not None:
                    cmd.extend(["-title", self.name])
                if self.keepterm:
                    cmd.append("-hold")
                cmd.extend(["-e", "cat <&%d" % rdesc])
                self.proc = subprocess.Popen(cmd, close_fds=False)
                os.close(rdesc)

        def _stop_unix(self):
            # type: () -> None
            if not self.keepterm:
                self.opened = False
                self.proc.kill()
                self.proc.wait()

    def start(self):
        # type: () -> None
        if WINDOWS:
            return self._start_windows()
        else:
            return self._start_unix()

    def stop(self):
        # type: () -> None
        if WINDOWS:
            return self._stop_windows()
        else:
            return self._stop_unix()

    def _print(self, s):
        # type: (str) -> None
        if self.newlines:
            s += "\n"
        if WINDOWS:
            wdesc = open(self.__f, "a")
            wdesc.write(s)
            wdesc.close()
        else:
            os.write(self.wdesc, s.encode())

    def push(self, msg):
        # type: (str) -> None
        self._print(str(msg))

    def high_push(self, msg):
        # type: (str) -> None
        self._print(str(msg))


class QueueSink(Sink):
    """
    Collects messages on the low and high entries into a :py:class:`Queue`.
    Messages are dequeued with :py:meth:`recv`.
    Both high and low entries share the same :py:class:`Queue`.

    .. code::

         +-------+
      >>-|--.    |->>
         | queue |
       >-|--'    |->
         +-------+
    """

    def __init__(self, name=None):
        # type: (Optional[str]) -> None
        Sink.__init__(self, name=name)
        self.q: queue.Queue[Any] = queue.Queue()

    def push(self, msg):
        # type: (Any) -> None
        self.q.put(msg)

    def high_push(self, msg):
        # type: (Any) -> None
        self.q.put(msg)

    def recv(self, block=True, timeout=None):
        # type: (bool, Optional[int]) -> Optional[Any]
        """
        Reads the next message from the queue.

        If no message is available in the queue, returns None.

        :param block: Blocks execution until a packet is available in the
                      queue. Defaults to True.
        :type block: bool
        :param timeout: Controls how long to wait if ``block=True``. If None
                        (the default), this method will wait forever. If a
                        non-negative number, this is a number of seconds to
                        wait before giving up (and returning None).
        :type timeout: None, int or float
        """
        try:
            return self.q.get(block=block, timeout=timeout)
        except queue.Empty:
            return None


class TransformDrain(Drain):
    """Apply a function to messages on low and high entry:

    .. code::

         +-------+
      >>-|--[f]--|->>
         |       |
       >-|--[f]--|->
         +-------+
    """

    def __init__(self, f, name=None):
        # type: (Callable[[Any], None], Optional[str]) -> None
        Drain.__init__(self, name=name)
        self.f = f

    def push(self, msg):
        # type: (Any) -> None
        self._send(self.f(msg))

    def high_push(self, msg):
        # type: (Any) -> None
        self._high_send(self.f(msg))


class UpDrain(Drain):
    """Repeat messages from low entry to high exit:

    .. code::

         +-------+
      >>-|    ,--|->>
         |   /   |
       >-|--'    |->
         +-------+
    """

    def push(self, msg):
        # type: (Any) -> None
        self._high_send(msg)

    def high_push(self, msg):
        # type: (Any) -> None
        pass


class DownDrain(Drain):
    r"""Repeat messages from high entry to low exit:

    .. code::

         +-------+
      >>-|--.    |->>
         |   \   |
       >-|    `--|->
         +-------+
    """

    def push(self, msg):
        # type: (Any) -> None
        pass

    def high_push(self, msg):
        # type: (Any) -> None
        self._send(msg)
