# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you 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.
# ruff: noqa: F841
"""RPC proxy, allows both client/server to connect and match connection.

In normal RPC, client directly connect to server's IP address.
Sometimes this cannot be done when server do not have a static address.
RPCProxy allows both client and server connect to the proxy server,
the proxy server will forward the message between the client and server.
"""

# pylint: disable=unused-variable, unused-argument
import asyncio
import errno
import logging
import os
import socket
import struct
import threading
import time

try:
    import tornado
    from tornado import gen, ioloop, websocket

    from . import tornado_util
except ImportError as error_msg:
    raise ImportError(
        f"RPCProxy module requires tornado package {error_msg}. Try 'pip install tornado'."
    )

from tvm.support.popen_pool import PopenWorker

from . import _ffi_api, base
from .base import TrackerCode
from .server import _server_env


class ForwardHandler:
    """Forward handler to forward the message."""

    def _init_handler(self):
        """Initialize handler."""
        self._init_message = b""
        self._init_req_nbytes = 4
        self._magic = None
        self.timeout = None
        self._rpc_key_length = None
        self._done = False
        self._proxy = ProxyServerHandler.current
        assert self._proxy
        self.rpc_key = None
        self.match_key = None
        self.forward_proxy = None
        self.alloc_time = None

    def __del__(self):
        logging.info("Delete %s...", self.name())

    def name(self):
        """Name of this connection."""
        return "RPCConnection"

    def _init_step(self, message):
        if self._magic is None:
            assert len(message) == 4
            self._magic = struct.unpack("<i", message)[0]
            if self._magic != base.RPC_MAGIC:
                logging.info("Invalid RPC magic from %s", self.name())
                self.close()
            self._init_req_nbytes = 4
        elif self._rpc_key_length is None:
            assert len(message) == 4
            self._rpc_key_length = struct.unpack("<i", message)[0]
            self._init_req_nbytes = self._rpc_key_length
        elif self.rpc_key is None:
            assert len(message) == self._rpc_key_length
            self.rpc_key = message.decode("utf-8")
            # match key is used to do the matching
            self.match_key = self.rpc_key[7:].split()[0]
            self.on_start()
        else:
            assert False

    def on_start(self):
        """Event when the initialization is completed"""
        self._proxy.handler_ready(self)

    def on_data(self, message):
        """on data"""
        assert isinstance(message, bytes)
        if self.forward_proxy:
            self.forward_proxy.send_data(message)
        else:
            while message and self._init_req_nbytes > len(self._init_message):
                nbytes = self._init_req_nbytes - len(self._init_message)
                self._init_message += message[:nbytes]
                message = message[nbytes:]
                if self._init_req_nbytes == len(self._init_message):
                    temp = self._init_message
                    self._init_req_nbytes = 0
                    self._init_message = b""
                    self._init_step(temp)
            if message:
                logging.info("Invalid RPC protocol, too many bytes %s", self.name())
                self.close()

    def on_error(self, err):
        logging.info("%s: Error in RPC %s", self.name(), err)
        self.close_pair()

    def close_pair(self):
        if self.forward_proxy:
            self.forward_proxy.signal_close()
            self.forward_proxy = None
        self.close()

    def on_close_event(self):
        """on close event"""
        assert not self._done
        logging.info("RPCProxy:on_close_event %s ...", self.name())
        if self.match_key:
            key = self.match_key
            if self._proxy._client_pool.get(key, None) == self:
                self._proxy._client_pool.pop(key)
            if self._proxy._server_pool.get(key, None) == self:
                self._proxy._server_pool.pop(key)
        self._done = True
        self.forward_proxy = None


class TCPHandler(tornado_util.TCPHandler, ForwardHandler):
    """Event driven TCP handler."""

    def __init__(self, sock, addr):
        super().__init__(sock)
        self._init_handler()
        self.addr = addr

    def name(self):
        return f"TCPSocketProxy:{self.addr[0]!s}:{self.rpc_key}"

    def send_data(self, message, binary=True):
        self.write_message(message, True)

    def on_message(self, message):
        self.on_data(message)

    def on_close(self):
        logging.info("RPCProxy: on_close %s ...", self.name())
        self._close_process = True

        if self.forward_proxy:
            self.forward_proxy.signal_close()
            self.forward_proxy = None
        self.on_close_event()


class WebSocketHandler(websocket.WebSocketHandler, ForwardHandler):
    """Handler for websockets."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._init_handler()

    def name(self):
        return f"WebSocketProxy:{self.rpc_key}"

    def on_message(self, message):
        self.on_data(message)

    def data_received(self, _):
        raise NotImplementedError()

    def send_data(self, message):
        try:
            self.write_message(message, True)
        except websocket.WebSocketClosedError as err:
            self.on_error(err)

    def on_close(self):
        logging.info("RPCProxy: on_close %s ...", self.name())
        if self.forward_proxy:
            self.forward_proxy.signal_close()
            self.forward_proxy = None
        self.on_close_event()

    def signal_close(self):
        self.close()


MIME_MAP = {
    "js": "text/javascript",
    "css": "text/css",
    "wasm": "application/wasm",
    "json": "application/json",
}


class RequestHandler(tornado.web.RequestHandler):
    """Handles html request."""

    def __init__(self, *args, **kwargs):
        file_path = kwargs.pop("file_path")
        self.format = file_path.split(".")[-1]

        if file_path.endswith("html"):
            self.page = open(file_path).read()
            web_port = kwargs.pop("rpc_web_port", None)
            if web_port:
                self.page = self.page.replace(
                    "ws://localhost:9190/ws", f"ws://localhost:{web_port}/ws"
                )
        else:
            self.page = open(file_path, "rb").read()

        super().__init__(*args, **kwargs)

    def data_received(self, _):
        pass

    def get(self, *args, **kwargs):
        if self.format in MIME_MAP:
            self.set_header("Content-Type", MIME_MAP[self.format])
        self.write(self.page)


class ProxyServerHandler:
    """Internal proxy server handler class."""

    current = None

    def __init__(
        self,
        sock,
        listen_port,
        web_port,
        timeout_client,
        timeout_server,
        tracker_addr,
        index_page=None,
        resource_files=None,
    ):
        assert ProxyServerHandler.current is None
        ProxyServerHandler.current = self
        if web_port:
            handlers = [(r"/ws", WebSocketHandler)]
            if index_page:
                handlers.append(
                    (r"/", RequestHandler, {"file_path": index_page, "rpc_web_port": web_port})
                )
                logging.info("Serving RPC index html page at http://localhost:%d", web_port)
            resource_files = resource_files if resource_files else []
            for item in resource_files:
                prefix, fname = item
                if not prefix.endswith("/"):
                    prefix += "/"
                if not prefix.startswith("/"):
                    prefix = "/" + prefix
                basename = os.path.basename(fname)
                pair = (rf"{prefix}{basename}", RequestHandler, {"file_path": fname})
                handlers.append(pair)
                logging.info(pair)
            self.app = tornado.web.Application(handlers)
            self.app.listen(web_port)

        self.sock = sock
        self.sock.setblocking(0)
        self.loop = ioloop.IOLoop.current()

        def event_handler(_, events):
            self._on_event(events)

        self.loop.add_handler(self.sock.fileno(), event_handler, self.loop.READ)
        self._client_pool = {}
        self._server_pool = {}
        self.timeout_alloc = 5
        self.timeout_client = timeout_client
        self.timeout_server = timeout_server
        # tracker information
        self._listen_port = listen_port
        self._tracker_addr = tracker_addr
        self._tracker_conn = None
        self._tracker_pending_puts = []
        self._key_set = set()
        self.update_tracker_period = 2
        if tracker_addr:
            logging.info("Tracker address:%s", str(tracker_addr))

            def _callback():
                self._update_tracker(True)

            self.loop.call_later(self.update_tracker_period, _callback)
        logging.info("RPCProxy: Websock port bind to %d", web_port)

    def _on_event(self, _):
        while True:
            try:
                conn, addr = self.sock.accept()
                TCPHandler(conn, addr)
            except OSError as err:
                if err.args[0] in (errno.EAGAIN, errno.EWOULDBLOCK):
                    break

    def _pair_up(self, lhs, rhs):
        lhs.forward_proxy = rhs
        rhs.forward_proxy = lhs

        lhs.send_data(struct.pack("<i", base.RPC_CODE_SUCCESS))
        lhs.send_data(struct.pack("<i", len(rhs.rpc_key)))
        lhs.send_data(rhs.rpc_key.encode("utf-8"))

        rhs.send_data(struct.pack("<i", base.RPC_CODE_SUCCESS))
        rhs.send_data(struct.pack("<i", len(lhs.rpc_key)))
        rhs.send_data(lhs.rpc_key.encode("utf-8"))
        logging.info("Pairup connect %s  and %s", lhs.name(), rhs.name())

    def _regenerate_server_keys(self, keys):
        """Regenerate keys for server pool"""
        keyset = set(self._server_pool.keys())
        new_keys = []
        # re-generate the server match key, so old information is invalidated.
        for key in tuple(keys):
            rpc_key, _ = base.split_random_key(key)
            handle = self._server_pool[key]
            del self._server_pool[key]
            new_key = base.random_key(rpc_key, keyset)
            self._server_pool[new_key] = handle
            keyset.add(new_key)
            new_keys.append(new_key)
        return new_keys

    def _update_tracker(self, period_update=False):
        """Update information on tracker."""
        try:
            if self._tracker_conn is None:
                self._tracker_conn = socket.socket(
                    base.get_addr_family(self._tracker_addr), socket.SOCK_STREAM
                )
                self._tracker_conn.connect(self._tracker_addr)
                self._tracker_conn.sendall(struct.pack("<i", base.RPC_TRACKER_MAGIC))
                magic = struct.unpack("<i", base.recvall(self._tracker_conn, 4))[0]
                if magic != base.RPC_TRACKER_MAGIC:
                    self.loop.stop()
                    raise RuntimeError(f"{self._tracker_addr} is not RPC Tracker")
                # just connect to tracker, need to update all keys
                self._tracker_pending_puts = self._server_pool.keys()

            if self._tracker_conn and period_update:
                # periodically update tracker information
                # regenerate key if the key is not in tracker anymore
                # and there is no in-coming connection after timeout_alloc
                base.sendjson(self._tracker_conn, [TrackerCode.GET_PENDING_MATCHKEYS])
                pending_keys = set(base.recvjson(self._tracker_conn))
                update_keys = []
                for k, v in self._server_pool.items():
                    if k not in pending_keys:
                        if v.alloc_time is None:
                            v.alloc_time = time.time()
                        elif time.time() - v.alloc_time > self.timeout_alloc:
                            update_keys.append(k)
                            v.alloc_time = None
                if update_keys:
                    logging.info(
                        "RPCProxy: No incoming conn on %s, regenerate keys...", str(update_keys)
                    )
                    new_keys = self._regenerate_server_keys(update_keys)
                    self._tracker_pending_puts += new_keys

            need_update_info = False
            # report new connections
            for key in self._tracker_pending_puts:
                rpc_key, _ = base.split_random_key(key)
                base.sendjson(
                    self._tracker_conn, [TrackerCode.PUT, rpc_key, (self._listen_port, key), None]
                )
                assert base.recvjson(self._tracker_conn) == TrackerCode.SUCCESS
                if rpc_key not in self._key_set:
                    self._key_set.add(rpc_key)
                    need_update_info = True

            if need_update_info:
                keylist = "[" + ",".join(self._key_set) + "]"
                cinfo = {"key": "server:proxy" + keylist, "addr": [None, self._listen_port]}
                base.sendjson(self._tracker_conn, [TrackerCode.UPDATE_INFO, cinfo])
                assert base.recvjson(self._tracker_conn) == TrackerCode.SUCCESS
            self._tracker_pending_puts = []
        except OSError as err:
            logging.info(
                "Lost tracker connection: %s, try reconnect in %g sec",
                str(err),
                self.update_tracker_period,
            )
            self._tracker_conn.close()
            self._tracker_conn = None
            self._regenerate_server_keys(self._server_pool.keys())

        if period_update:

            def _callback():
                self._update_tracker(True)

            self.loop.call_later(self.update_tracker_period, _callback)

    def _handler_ready_tracker_mode(self, handler):
        """tracker mode to handle handler ready."""
        if handler.rpc_key.startswith("server:"):
            key = base.random_key(handler.match_key, cmap=self._server_pool)
            handler.match_key = key
            self._server_pool[key] = handler
            self._tracker_pending_puts.append(key)
            self._update_tracker()
        else:
            if handler.match_key in self._server_pool:
                self._pair_up(self._server_pool.pop(handler.match_key), handler)
            else:
                handler.send_data(struct.pack("<i", base.RPC_CODE_MISMATCH))
                handler.signal_close()

    def _handler_ready_proxy_mode(self, handler):
        """Normal proxy mode when handler is ready."""
        if handler.rpc_key.startswith("server:"):
            pool_src, pool_dst = self._client_pool, self._server_pool
            timeout = self.timeout_server
        else:
            pool_src, pool_dst = self._server_pool, self._client_pool
            timeout = self.timeout_client

        key = handler.match_key
        if key in pool_src:
            self._pair_up(pool_src.pop(key), handler)
            return
        if key not in pool_dst:
            pool_dst[key] = handler

            def cleanup():
                """Cleanup client connection if timeout"""
                if pool_dst.get(key, None) == handler:
                    logging.info(
                        "Timeout client connection %s, cannot find match key=%s",
                        handler.name(),
                        key,
                    )
                    pool_dst.pop(key)
                    handler.send_data(struct.pack("<i", base.RPC_CODE_MISMATCH))
                    handler.signal_close()

            self.loop.call_later(timeout, cleanup)
        else:
            logging.info("Duplicate connection with same key=%s", key)
            handler.send_data(struct.pack("<i", base.RPC_CODE_DUPLICATE))
            handler.signal_close()

    def handler_ready(self, handler):
        """Report handler to be ready."""
        logging.info("Handler ready %s", handler.name())
        if self._tracker_addr:
            self._handler_ready_tracker_mode(handler)
        else:
            self._handler_ready_proxy_mode(handler)

    def run(self):
        """Run the proxy server"""
        ioloop.IOLoop.current().start()


def _proxy_server(
    listen_sock,
    listen_port,
    web_port,
    timeout_client,
    timeout_server,
    tracker_addr,
    index_page,
    resource_files,
):
    asyncio.set_event_loop(asyncio.new_event_loop())
    handler = ProxyServerHandler(
        listen_sock,
        listen_port,
        web_port,
        timeout_client,
        timeout_server,
        tracker_addr,
        index_page,
        resource_files,
    )
    handler.run()


class PopenProxyServerState:
    """Internal PopenProxy State for Popen"""

    current = None

    def __init__(
        self,
        host,
        port=9091,
        port_end=9199,
        web_port=0,
        timeout_client=600,
        timeout_server=600,
        tracker_addr=None,
        index_page=None,
        resource_files=None,
    ):
        sock = socket.socket(base.get_addr_family((host, port)), socket.SOCK_STREAM)
        self.port = None
        for my_port in range(port, port_end):
            try:
                sock.bind((host, my_port))
                self.port = my_port
                break
            except OSError as sock_err:
                if sock_err.errno in [errno.EADDRINUSE]:
                    continue
                raise sock_err
        if not self.port:
            raise ValueError(f"cannot bind to any port in [{port}, {port_end})")
        logging.info("RPCProxy: client port bind to %s:%d", host, self.port)
        sock.listen(1)
        self.thread = threading.Thread(
            target=_proxy_server,
            args=(
                sock,
                self.port,
                web_port,
                timeout_client,
                timeout_server,
                tracker_addr,
                index_page,
                resource_files,
            ),
        )
        # start the server in a different thread
        # so we can return the port directly
        self.thread.start()


def _popen_start_proxy_server(
    host,
    port=9091,
    port_end=9199,
    web_port=0,
    timeout_client=600,
    timeout_server=600,
    tracker_addr=None,
    index_page=None,
    resource_files=None,
):
    # This is a function that will be sent to the
    # Popen worker to run on a separate process.
    # Create and start the server in a different thread
    state = PopenProxyServerState(
        host,
        port,
        port_end,
        web_port,
        timeout_client,
        timeout_server,
        tracker_addr,
        index_page,
        resource_files,
    )
    PopenProxyServerState.current = state
    # returns the port so that the main can get the port number.
    return state.port


class Proxy:
    """Start RPC proxy server on a separate process.

    Python implementation based on PopenWorker.

    Parameters
    ----------
    host : str
        The host url of the server.

    port : int
        The TCP port to be bind to

    port_end : int, optional
        The end TCP port to search

    web_port : int, optional
        The http/websocket port of the server.

    timeout_client : float, optional
        Timeout of client until it sees a matching connection.

    timeout_server : float, optional
        Timeout of server until it sees a matching connection.

    tracker_addr: Tuple (str, int) , optional
        The address of RPC Tracker in tuple (host, ip) format.
        If is not None, the server will register itself to the tracker.

    index_page : str, optional
        Path to an index page that can be used to display at proxy index.

    resource_files : str, optional
        Path to local resources that can be included in the http request
    """

    def __init__(
        self,
        host,
        port=9091,
        port_end=9199,
        web_port=0,
        timeout_client=600,
        timeout_server=600,
        tracker_addr=None,
        index_page=None,
        resource_files=None,
    ):
        self.proc = PopenWorker()
        # send the function
        self.proc.send(
            _popen_start_proxy_server,
            [
                host,
                port,
                port_end,
                web_port,
                timeout_client,
                timeout_server,
                tracker_addr,
                index_page,
                resource_files,
            ],
        )
        # receive the port
        self.port = self.proc.recv()
        self.host = host

    def terminate(self):
        """Terminate the server process"""
        if self.proc:
            logging.info("Terminating Proxy Server...")
            self.proc.kill()
            self.proc = None

    def __del__(self):
        try:
            self.terminate()
        except ImportError:
            pass


def websocket_proxy_server(url, key=""):
    """Create a RPC server that uses an websocket that connects to a proxy.

    Parameters
    ----------
    url : str
        The url to be connected.

    key : str
        The key to identify the server.
    """

    def create_on_message(conn):
        def _fsend(data):
            data = bytes(data)
            conn.write_message(data, binary=True)
            return len(data)

        on_message = _ffi_api.CreateEventDrivenServer(_fsend, "WebSocketProxyServer", "%toinit")
        return on_message

    @gen.coroutine
    def _connect(key):
        conn = yield websocket.websocket_connect(url)
        on_message = create_on_message(conn)
        temp = _server_env(None)
        # Start connecton
        conn.write_message(struct.pack("<i", base.RPC_MAGIC), binary=True)
        key = "server:" + key
        conn.write_message(struct.pack("<i", len(key)), binary=True)
        conn.write_message(key.encode("utf-8"), binary=True)
        msg = yield conn.read_message()
        assert len(msg) >= 4
        magic = struct.unpack("<i", msg[:4])[0]
        if magic == base.RPC_CODE_DUPLICATE:
            raise RuntimeError(f"key: {key} has already been used in proxy")
        if magic == base.RPC_CODE_MISMATCH:
            logging.info("RPCProxy do not have matching client key %s", key)
        elif magic != base.RPC_CODE_SUCCESS:
            raise RuntimeError(f"{url} is not RPC Proxy")
        msg = msg[4:]

        logging.info("Connection established with remote")

        if msg:
            on_message(bytearray(msg), 3)

        while True:
            try:
                msg = yield conn.read_message()
                if msg is None:
                    break
                on_message(bytearray(msg), 3)
            except websocket.WebSocketClosedError as err:
                break
        logging.info("WebSocketProxyServer closed...")
        temp.remove()
        ioloop.IOLoop.current().stop()

    ioloop.IOLoop.current().spawn_callback(_connect, key)
    ioloop.IOLoop.current().start()
