"""
This example demonstrates programming with blatann in an event-driven, object-oriented manner.
The BLE device is set up in the main context, however all logic past that point is done using
event callbacks.
The main context is blocked by a "GenericWaitable", which is notified when the program
completes its intended function.

The program's logic itself is equivalent to the central example, where it connects and pairs to a device,
registers a notification callback for the counting characteristic, then tests out the conversion of strings to hex.

One thing to note: when using event-driven callbacks, it is imperative that the callbacks themselves do
not ever block on events (i.e. use the .wait() functionality). If this happens, you are essentially blocking
the event thread from processing any more events and will wait indefinitely.
A good rule of thumb when using blatann is just to not mix blocking and non-blocking calls.

This is designed to work alongside the peripheral example running on a separate nordic chip
"""
import struct
from blatann import BleDevice
from blatann.gap import smp
from blatann.examples import example_utils, constants
from blatann.waitables import GenericWaitable
from blatann.nrf import nrf_events

logger = example_utils.setup_logger(level="DEBUG")


class HexConverterTest(object):
    """
    Class to perform the hex conversion process. It is passed in the hex conversion characteristic
    and the waitable to signal when the process completes
    """
    def __init__(self, characteristic, waitable):
        """
        :param characteristic: The hex conversion characteristic to use
        :type characteristic: blatann.gatt.gattc.GattcCharacteristic
        :param waitable: The waitable to notify when the testing ends
        :type waitable: GenericWaitable
        """
        self.char = characteristic
        self.waitable = waitable
        self.i = 1
        # Generate some data, "ABCDEFG..."
        self.data_to_convert = bytes(ord('A') + i for i in range(12))

    def start(self):
        """
        Starts a new hex conversion process by writing the data to the peripheral's characteristic
        """
        data_to_send = self.data_to_convert[:self.i]
        logger.info("Converting to hex data: '{}'".format(data_to_send))
        # Write the data, and setup the _write_complete callback to be executed when the write finishes
        self.char.write(data_to_send).then(self._write_complete)

    def _write_complete(self, characteristic, event_args):
        """
        Event callback for when the hex data write completes.
        Initiates the read on the characteristic

        :param characteristic: The characteristic itself (hex converter characteristic, should always match self.char)
        :param event_args: The write event args
        :type event_args: blatann.event_args.WriteCompleteEventArgs
        """
        # At this point we should probably check event_args.status to make sure it was written successfully
        # Initiate the read, and setup _read_complete to be called when it finishes
        self.char.read().then(self._read_complete)

    def _read_complete(self, characteristic, event_args):
        """
        Event callback for when the hex characteristic read completes.
        Checks to see if the process is complete, if not starts another conversion

        :param characteristic: The characteristic itself (hex converter characteristic, should always match self.char)
        :param event_args: The read event args
        :type event_args: blatann.event_args.ReadCompleteEventArgs
        """
        logger.info("Hex: '{}'".format(event_args.value.decode("ascii")))
        self.i += 1
        if self.i > len(self.data_to_convert):
            # Done, notify the main thread that we are done
            self.waitable.notify()
        else:
            # Not done, start the next conversion
            self.start()


class MyPeripheralConnection(object):
    """
    Class to handle the post-connection database discovery and pairing process
    """
    def __init__(self, peer, waitable):
        """
        :param peer: The peer that was connected to
        :type peer: blatann.peer.Peripheral
        :param waitable: The waitable to notify to end the process
        :type waitable: GenericWaitable
        """
        self.peer = peer
        self.waitable = waitable
        self._start_db_discovery()

    def _start_db_discovery(self):
        """
        Initiates database discovery
        """
        self.peer.discover_services().then(self._on_db_discovery)

    def _on_db_discovery(self, peer, event_args):
        """
        Event callback for when database discovery completes

        :param peer: The peer the database discovery completed on (should equal self.peer)
        :type peer: blatann.peer.Peripheral
        :param event_args: The event arguments
        :type event_args: blatann.event_args.DatabaseDiscoveryCompleteEventArgs
        """

        logger.info("Service discovery complete! status: {}".format(event_args.status))
        # The peer's database is now current, log out the services found
        for service in peer.database.services:
            logger.info(service)

        # Find and subscribe to the counting characteristic
        counting_char = self.peer.database.find_characteristic(constants.COUNTING_CHAR_UUID)
        if counting_char:
            logger.info("Subscribing to the counting characteristic")
            counting_char.subscribe(self._on_counting_char_notification)
        else:
            logger.warning("Failed to find counting characteristic")

        # Initiate the pairing process
        self._start_pairing()

    def _start_pairing(self):
        """
        Sets up the security parameters and starts the pairing process
        """
        self.peer.security.set_security_params(passcode_pairing=True, io_capabilities=smp.IoCapabilities.KEYBOARD_DISPLAY,
                                               bond=False, out_of_band=False)
        self.peer.security.on_passkey_required.register(self._on_passkey_entry)
        self.peer.security.pair().then(self._on_pair_complete)

    def _on_passkey_entry(self, peer, event_args):
        """
        Event callback for when a passkey is required to be entered by the user
        Requests the user to enter the passkey and resolves the event with the passkey entered

        :param peer: the peer the passkey is for
        :param event_args: The event args
        :type event_args: blatann.event_args.PasskeyEntryEventArgs
        """
        passkey = input("Enter peripheral passkey: ")
        event_args.resolve(passkey)

    def _on_pair_complete(self, peer, event_args):
        """
        Event callback for when pairing completes. Finds the hex conversion characteristic in the database
        and starts the hex converter testing

        :param peer: The peer the pairing completed on
        :param event_args: The event args
        :type event_args: blatann.event_args.PairingCompleteEventArgs
        """
        # At this point, we should probably check event_args.status to verify that pairing completed successfully

        # Find the hex converter char. If found, start the testing. Otherwise, notify the main thread to exit
        hex_convert_char = self.peer.database.find_characteristic(constants.HEX_CONVERT_CHAR_UUID)
        if hex_convert_char:
            self.hex_counter = HexConverterTest(hex_convert_char, self.waitable)
            self.hex_counter.start()
        else:
            logger.warning("Failed to find hex convert char")
            self.waitable.notify()

    def _on_counting_char_notification(self, characteristic, event_args):
        """
            Callback for when a notification is received from the peripheral's counting characteristic.
            The peripheral will periodically notify a monotonically increasing, 4-byte integer. This callback unpacks
            the value and logs it out

            :param characteristic: The characteristic the notification was on (counting characteristic)
            :param event_args: The event arguments
            :type event_args: blatann.event_args.NotificationReceivedEventArgs
            """
        # Unpack as a little-endian, 4-byte integer
        current_count = struct.unpack("<I", event_args.value)[0]
        logger.info("Counting char notification. Current count: {}".format(current_count))


class ConnectionManager(object):
    """
    Manages scanning and connecting to the target peripheral
    """
    def __init__(self, ble_device, exit_waitable):
        """
        :param ble_device: The BLE Device to operate
        :type ble_device: BleDevice
        :param exit_waitable: The waitiable to notify to exit the program
        :type exit_waitable: GenericWaitable
        """
        self.ble_device = ble_device
        self.exit_waitable = exit_waitable
        self.target_device_name = ""
        self.connection = None
        self.peer = None

    def _on_connect(self, peer):
        """
        Event callback for when the peer's connection process completes.
        If the connection failed, will exit the program. Otherwise, will create
        a MyPeripheralConnection to manage the connection process

        :param peer: The peer that connected, or None if the connection process failed
        :type peer: blatann.peer.Peripheral
        """
        if not peer:
            logger.warning("Timed out connecting to device")
            self.exit_waitable.notify()
        else:
            logger.info("Connected, conn_handle: {}".format(peer.conn_handle))
            self.peer = peer
            # Connect the disconnect event to the exit waitable, so if
            # the peripheral disconnects from us unexpectedly the program terminates
            peer.on_disconnect.register(self.exit_waitable.notify)
            # Create the connection
            self.connection = MyPeripheralConnection(peer, self.exit_waitable)

    def _on_scan_report(self, scan_report):
        """
        Event callback when a scan report completes.
        Searches for the target advertised peripheral name. If found, starts the connection process.
        Otherwise, the program terminates

        :param scan_report: The complete scan report from the scanning session
        :type scan_report: blatann.gap.scanning.ScanReportCollection
        """
        for report in scan_report.advertising_peers_found:
            if report.advertise_data.local_name == self.target_device_name:
                logger.info("Found match: connecting to address {}".format(report.peer_address))
                self.ble_device.connect(report.peer_address).then(self._on_connect)
                return

        logger.info("Did not find target peripheral")
        self.exit_waitable.notify()

    def scan_and_connect(self, name, timeout=4):
        """
        Starts the scanning process and sets up the callback for when scanning completes

        :param name: The name of the peripheral to look for
        :param timeout: How long to scan for
        """
        logger.info("Scanning for '{}'".format(name))
        self.target_device_name = name
        self.ble_device.scanner.set_default_scan_params(timeout_seconds=timeout)
        self.ble_device.scanner.start_scan().then(self._on_scan_report)


def main(serial_port):
    target_device_name = constants.PERIPHERAL_NAME
    # Create a waitable that will block the main thread until notified by one of the classes above
    main_thread_waitable = GenericWaitable()

    # Create and open the BLE device (and suppress spammy logs)
    ble_device = BleDevice(serial_port)
    ble_device.event_logger.suppress(nrf_events.GapEvtAdvReport)
    ble_device.open()

    # Create the connection manager and start the scanning process
    s = ConnectionManager(ble_device, main_thread_waitable)
    s.scan_and_connect(target_device_name)

    # Block the main thread indefinitely until the program finishes
    main_thread_waitable.wait()

    # Clean up, disconnect if a peer was connected to
    if s.peer:
        s.peer.disconnect().wait()
    ble_device.close()


if __name__ == '__main__':
    main("COM9")
