# Source code for emucore_direct.client

    """
    Client gRPC services for running on FPGA reservoir computer EmuCore developed by QCi.
    """
    import logging
    import json
    import time
    from typing import List, TypedDict
    import os
    import grpc
    from grpc._channel import _InactiveRpcError
    import numpy as np
    from . import emucore_pb2, emucore_pb2_grpc
    from .utils import set_filter_coefficients, \
        bytes_to_array, \
        prep_input_data, \
        message_to_dict

    grpc_service_config = json.dumps(
        {
            "methodConfig": [
                {
                    "name": [{"service": "EmuCore.EmuCoreService"}],
                    "retryPolicy": {
                        "maxAttempts": 5, # this is the max number of attempts
                        "initialBackoff": "0.2s",
                        "maxBackoff": "10s",
                        "backoffMultiplier": 2.5,
                        "retryableStatusCodes": ["UNAVAILABLE"],
                    },
                }
            ]
        }
    )

    MAX_INPUT_SIZE = 20*1024*1024
    # Why is it set to this length?
    # modifies Electro-Optical Modulator (EOM) coefs

    class StatusMessage(TypedDict):
        """
        Structure of responses for configuration requests to EmuCore device.

        :param status: the status of the request
        :param message: a description for the recieved status
        """
        status: int
        message: str

    class InactiveRpcError(Exception):
        """Custom exception wrapper around grpc._channel._InactiveRpcError."""

    class EmuCoreClient:
        """
        Provides services for accessing EmuCore server

        :param ip_addr: the IP address of the gRPC server
        :param port:
            The port that the RPC server is running on
        :param max_data_size: int
            The max send and recieve message length for RPC server

        .. note::

           :code:`lock_id` is used by a variety of class functions.
           It is set to an empty string by default since default for device server
           :code:`lock_id` is also an empty string. This allows for single user
           processing without having to acquire a device lock.

        .. All GRPC calls follow a specific pattern:
        ..   1. Fill in data to be sent in message stub
        ..   2. Send data using stub service method
        ..   3. Parse response
        """
        def __init__(
            self,
            ip_addr: str = os.getenv("DEVICE_IP_ADDRESS", "localhost"),
            port: str = os.getenv("DEVICE_PORT", "50051"),
            max_data_size: int =512 * 1024 * 1024,
        ):
            self._ip_add_port = ip_addr + ":" + port
            self._channel_opt = [
                ("grpc.max_send_message_length", max_data_size),
                ("grpc.max_receive_message_length", max_data_size),
                ("grpc.service_config", grpc_service_config),
            ]
            self.channel = grpc.insecure_channel(
                self._ip_add_port, options=self._channel_opt
            )
            self.stub = emucore_pb2_grpc.EmuCoreServiceStub(self.channel)

        def check_lock(self, lock_id: str = "") -> dict:
            """
            Checks if submitted :code:`lock_id` has execution lock on the device

            :param lock_id: a UUID which will be checked to determine if has exclusive
                device execution lock
            :return: a member of :class:`eqc_direct.utils.LockCheckStatus` as a dict:

               - **status_code**: `int`- status code for lock check
               - **status_desc**: `str`- a description for the associated status code
            """
            check_input = emucore_pb2.lock_message(lock_id=lock_id)
            check_output = self.eqc_stub.check_lock(check_input)
            return message_to_dict(check_output)

        def reservoir_reset(self,
                            lock_id) -> StatusMessage:
            """
            Resets a reservoir instance by clearing RAM on the server

            :param lock_id: a lock_id which has an active reserve on the device

            :return:
               dictionary with with values from members of
               :class:`emucore_direct.types.StatusResponses`
            """
            reset_message = emucore_pb2.lock_message(lock_id=lock_id)
            return message_to_dict(self.stub.reservoir_reset(reset_message))

        def system_info(self) -> dict:
            """
            Provides system info on call

            :return:
               dict with following keys:

               - **system_name**: `str`- product name
               - **system_version**: `str`- server version

            """
            sys_info_resp = self.stub.system_info(emucore_pb2.empty_message())
            return message_to_dict(sys_info_resp)

        def acquire_lock(self) -> dict:
            """
            Attempts to acquire exclusive lock for submitting jobs

            :return:
               a member of :class:`emucore_direct.types.LockManageStatus` as a dict along
               with an additional key :code:`lock_id`:

               - **lock_id**: `str`- if acquired the current device `lock_id`
                 else empty string
               - **status**: `int`- status code for lock id acquisition
               - **message**: `str`- a description for the associated status code
            """
            try:
                acquire_lock_resp = self.stub.acquire_lock(emucore_pb2.empty_message())
            except _InactiveRpcError as exc:
                raise InactiveRpcError(
                    "acquire_lock failed due to grpc._channel._InactiveRpcError."
                ) from exc
            return message_to_dict(acquire_lock_resp)

        def release_lock(self, lock_id):
            """
            Releases exclusive lock for submitting data to reservoir

            :param lock_id: a UUID with currently acquired exclusive device lock
            :return:
               a dict with the following keys:

               - **lock_released**: `bool`- if released is True else False
               - **message**: `str`- a description of release operation result
            """
            release_input = emucore_pb2.lock_message(lock_id = lock_id)
            try:
                release_lock_resp = self.stub.release_lock(release_input)
            except _InactiveRpcError as exc:
                raise InactiveRpcError(
                    "release_lock failed due to grpc._channel._InactiveRpcError."
                ) from exc
            return message_to_dict(release_lock_resp)

        def rc_config(self,
                   lock_id: str,
                   vbias: float,
                   gain: float,
                   num_nodes: int,
                   num_taps: int) -> StatusMessage:
            """
            Configures reservoir model and how data will be processed by the reservoir.

            :param lock_id: a lock_id which has an active reserve on the device
            :param vbias: bias to apply to each node in reservoir. Range for parameter [0,1].
            :param gain: memory setting for system how long should inputs effect reservoir similar
                   to beta in adaptive gradient descent range for parameter [0,1]
            :param num_nodes: the total number of hidden nodes to instantiate within the reservoir,
                   a single hidden layer
            :param num_taps: number of connections in reservoir, generally should be set to less
               than the number of nodes in reservoir. Defines interconnection between nodes.
            :return: dictionary with with values from one of the members of
                  :class:`emucore_direct.types.StatusResponses`
            """
            filter_coefs = set_filter_coefficients(num_taps=num_taps)
            config_message = emucore_pb2.rc_config_message(lock_id=lock_id,
                                                       vbias = vbias,
                                                       gain = gain,
                                                       num_nodes = num_nodes,
                                                       num_taps = num_taps)
            rc_status = self.stub.rc_config(config_message)
            return message_to_dict(rc_status)

        def rc_run(self,
                   lock_id: str,
                   reservoir_input: List[int],):
            """
            Runs a series of data through the reservoir and returns response from device
            based on current reservoir configuration.

            :param lock_id: a lock_id which has an active reserve on the device
            :param reservoir_input: a list of digitized values to input to the reservoir must be
                   less than MAX_INPUT_SIZE
            :return: a dictionary with the folowing keys:

               - **status**: `int`- the status for the reservoir submission
               - **message**: `str`- a description of the status for the submission
               - **states**: `bytes`- response from reservoir as bytes.
            """
            assert MAX_INPUT_SIZE>=len(reservoir_input), \
                f"Input to reservoir must be of less than or equal to {MAX_INPUT_SIZE} was {len(reservoir_input)}"
            run_message = emucore_pb2.rc_run_message(lock_id=lock_id,
                                                       input = reservoir_input,)
            rc_response = self.stub.rc_run(run_message)
            return message_to_dict(rc_response)

        def wait_for_lock(self):
            """
            Waits for lock indefinitely calling :func:`acquire_lock`

            :return: a tuple of the following items:

               - **lock_id**: `str`- exclusive lock for device execution with a timeout
               - **start_queue_ts**: `int`- time in ns when began lock acquisition.
               - **end_queue_ts**: `int`- time in ns when lock was acquired.
            """
            lock_id = ""
            start_queue_ts = time.time_ns()
            while lock_id == "":
                lock_id = self.acquire_lock()["lock_id"]
                # only sleep if didn't get lock on device
                if lock_id == "":
                    time.sleep(1)
            end_queue_ts = time.time_ns()
            return lock_id, start_queue_ts, end_queue_ts

        def process_all_data(self,
                             lock_id: str,
                             input_data: np.ndarray,
                             num_nodes: int,
                             density: float,
                             feature_scaling: float,
                             max_scale_val: float=None,
                             weights: np.ndarray=None,
                             seed_val_weights: int=13):
            """
            Run dataset through reservoir:

            1. Get lock
            2. Apply scaling and random weights mask to input data
            3. Run data through reservoir
            4. Combine data from reservoir responses and reshape based on number of nodes
            5. Release lock

            :param lock_id: a UUID that currently has lock on the device
            :param input_data: data or series to process via reservoir
            :param num_nodes: the total number of hidden nodes to instantiate within the reservoir,
                a single hidden layer (this is also used to apply random weights to to the data
                as well as reshape data recieved from reservoir back to correct output dimension)
            :param feature_scaling: after applying max abs scalar feature scaling factor applied
            :param max_scale_val: max absolute value used to scale data if provided
            :param seed_val_weights: seeds randomness for weigths to allow for reproducibility
            :note: if doing multiple runs without reset the max value mustn't exceed original
                data max value in order for results to be processed properly.

            :return:
               a tuple of the following elements:

               - **reservoir_response**: `np.ndarray`- reservoir response represented as an array dimension of array will be nrows of input matrix by num nodes.
               - **max_scale_value**: `np.ndarray`- the scaling value that was applied to the input data before it was processed by the reservoir.
               - **weights**: `np.ndarray`- the weights that were used to apply the random mask to the data prior to being processed by the reservoir.
            """
            n_rows, n_cols = input_data.shape
            input_packets, max_scale_val, weights = prep_input_data(
                input_data=input_data,
                num_nodes=num_nodes,
                density=density,
                feature_scaling=feature_scaling,
                max_scale_val=max_scale_val,
                weights=weights,
                seed_val_weights=seed_val_weights)
            try:
                reservoir_resp = np.array([])
                for i in input_packets:
                    packet_resp = self.rc_run(
                        lock_id=lock_id,
                        reservoir_input=i)
                    packet_bytes = bytearray(np.array(packet_resp["states"]))
                    packet_arr = bytes_to_array(input_bytes=packet_bytes)
                    reservoir_resp = np.concatenate((reservoir_resp, packet_arr),axis=0)
            except Exception as err:
                print("ERROR OCCURRED", err)
                raise RuntimeError("Error while processing data") from err
            return np.array(reservoir_resp).reshape(n_rows,num_nodes), max_scale_val, weights