#!/usr/bin/env python3
"""
This example demonstrates how you can use the BrainStem REST API over a serial port.
This feature allows you to communicate with a compatible BrainStem device without needing the BrainStem libray.
Note: The example does use the brainstem library (import brainstem) to configure the serial port; however, 
      this could be done as part of a configuration step before deployment.
"""

# Standard library dependencies.
import json
import sys

# External dependencies.
import brainstem
from brainstem import _BS_C

import serial

# Constants for the serial port and baud rate.
# TODO: The user must manually update this to the correct serial port for their system.
SERIAL_PORT = "/dev/tty.usbserial-115340"
BAUD_RATE = 115200
READ_TIMEOUT_SECONDS = 2.0

# Constants for endpoints
LED_ENDPOINT = "api/v1/brainstem/system/0/led"

#List of API to execute.
#Note: A single newline indicates the end of a message.
MESSAGES = [
    "get api/v1/brainstem/uart/0/protocol\n",
    "get api/v1/brainstem/uart/0/baudrate\n",
    "get api/v1/brainstem/uart/0/baud\n",               #Bad command; error response expected.
    "put " + LED_ENDPOINT + " {\"value\": true}\n",     #Turn LED on. - Value is a boolean.
    "get " + LED_ENDPOINT + "\n",                       #Get the current LED state.
    "put " + LED_ENDPOINT + " {\"value\": false}\n",    #Turn LED off.
    "get " + LED_ENDPOINT + "\n",                       #Get the current LED state.
]

#Note: This could be pre configured in saved so that you program needs
#      no access to the USB connection of the device. 
def configure_stem_uart_for_rest_serial() -> None:
    """Configure the UART for REST serial."""

    #This code will find and connect to the first BrainStem device it finds.
    #If you have multiple devices connected you should supply a serial number to discoverAndConnect.
    stem = brainstem.stem.Module(0)
    result = stem.discoverAndConnect(brainstem.link.Spec.USB)

    if result != brainstem.result.Result.NO_ERROR:
        print(f"Failed to connect to stem: {result}")
        sys.exit(1)

    result = stem.classQuantity(_BS_C.cmdUART)
    if result.error != brainstem.result.Result.NO_ERROR:
        print(f"Failed to get class quantity: {result}")
        sys.exit(1)
    if result.value == 0:
        print("This device does not support UART communication")
        sys.exit(1)

    uart = brainstem.entity.UART(stem, 0)

    error = uart.setProtocol(7) # _BS_C.uartProtocol_RestSerial_Value - Dosen't exist yet. 
    if error != brainstem.result.Result.NO_ERROR:
        print(f"Failed to set protocol: {error}")
        sys.exit(1)

    result = uart.getProtocol()
    if result.error != brainstem.result.Result.NO_ERROR:
        print(f"Failed to get protocol: {result}")
        sys.exit(1)
    print(f"Protocol: {result.value}")

    error = uart.setBaudRate(BAUD_RATE)
    if error != brainstem.result.Result.NO_ERROR:
        print(f"Failed to set baud rate: {error}")
        sys.exit(1)

    result = uart.getBaudRate()
    if result.error != brainstem.result.Result.NO_ERROR:
        print(f"Failed to get baud rate: {result}")
        sys.exit(1)
    print(f"Baud rate: {result.value}")

    error = uart.setEnable(True)
    if error != brainstem.result.Result.NO_ERROR:
        print(f"Failed to set enable: {error}")
        sys.exit(1)

    result = uart.getEnable()
    if result.error != brainstem.result.Result.NO_ERROR:
        print(f"Failed to get enable status: {result}")
        sys.exit(1)
    print(f"Enable status: {result.value}")

    stem.disconnect()


def _is_error_response(resp: object) -> bool:
    return "errorMessage" in resp or "errorCode" in resp


def _is_value_response(resp: object) -> bool:
    return "value" in resp or "rawValue" in resp


def _is_empty_success_response(resp: dict) -> bool:
    return len(resp) == 0


def _print_parsed_response(resp: object) -> None:
    """Print fields from the JSON ``response`` object."""
    if not isinstance(resp, dict):
        print(f"response is not an object: {resp!r}", file=sys.stderr)
        return

    if _is_error_response(resp):
        print("Parsed Response - Error response:")
        ec = resp.get("errorCode")
        em = resp.get("errorMessage")
        print(f"errorCode: {ec}")
        print(f"errorMessage: {em}")
        print("\n\n")
        return

    if _is_value_response(resp):
        print("Parsed Response - Value response:")
        print(f"value: {resp.get('value')}")
        print(f"rawValue: {resp.get('rawValue')}")
        print("\n\n")
        return

    if _is_empty_success_response(resp):
        print("Parsed Response - Success (empty response object)")
        print("\n\n")
        return

    print(f"Unrecognized response shape: {resp!r}", file=sys.stderr)


def _parse_response(data: str) -> dict | None:
    try:
        obj = json.loads(data)
    except json.JSONDecodeError as e:
        print(f"Invalid JSON: {e}", file=sys.stderr)
        return None

    resp = obj.get("response")
    if resp is None:
        print('Top-level JSON has no "response" key.', file=sys.stderr)
        return None

    return resp


def process_response(data: str) -> dict | None:
    print(f"Raw response:\n{data}", end="" if data.endswith("\n") else "\n")
    resp = _parse_response(data)
    if resp is not None:
        _print_parsed_response(resp)
    return resp


def handle_command_exchange(ser: serial.Serial, msg: str) -> dict | None:
    """Handles a full request/response cycle for a single message."""
    ser.write(msg.encode("utf-8"))
    ser.flush()
    data = read_until_double_newline(ser)
    return process_response(data)


def read_until_double_newline(ser: serial.Serial) -> str:
    """Accumulate bytes until a double newline is detected."""
    buf = bytearray()
    while True:
        chunk = ser.read(max(1, ser.in_waiting))
        if not chunk:
            print("Timeout or EOF while waiting for double newline.", file=sys.stderr)
            sys.exit(1)
        buf.extend(chunk)
        raw = bytes(buf)

        #Response is terminated by double newline.
        sep = b"\n\n"
        if raw.endswith(sep):
            return raw.decode("utf-8", errors="replace")


def main() -> None:

    #This can be removed if the device is already configured for REST serial.
    configure_stem_uart_for_rest_serial()

    try:
        ser = serial.Serial(
            port=SERIAL_PORT,
            baudrate=BAUD_RATE,
            timeout=READ_TIMEOUT_SECONDS,
        )
    except serial.SerialException as e:
        print(f"Failed to open serial port {SERIAL_PORT}: {e}", file=sys.stderr)
        sys.exit(1)

    try:
        # Send each message in the list.
        for msg in MESSAGES:
            handle_command_exchange(ser, msg)

    finally:
        ser.close()


if __name__ == "__main__":
    main()
