#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# License: BSD
#   https://raw.githubusercontent.com/splintered-reality/py_trees/devel/LICENSE
#
##############################################################################
# Documentation
##############################################################################

"""
Blackboards are not a necessary component of behaviour tree implementations,
but are nonetheless, a fairly common mechanism for sharing data between
behaviours in the tree. See, for example, the `design notes`_
for blackboards in Unreal Engine.

.. image:: images/blackboard.jpg
   :width: 300px
   :align: center

Implementations vary widely depending on the needs of
the framework using them. The simplest implementations take the
form of a key-value store with global access, while more
rigorous implementations scope access or form a secondary
graph overlaying the tree connecting data ports between behaviours.

The *'Zen of PyTrees'* is to enable rapid development, yet be rich
enough so that *all* of the magic is exposed for debugging purposes.
The first implementation of a blackboard was merely a global key-value
store with an api that lent itself to ease of use, but did not
expose the data sharing between behaviours which meant any tooling
used to introspect or visualise the tree, only told half the story.

The current implementation adopts a strategy similar to that of a
filesystem. Each client (subsequently behaviour) registers itself
for read/write access to keys on the blackboard. This is less to
do with permissions and more to do with tracking users of keys
on the blackboard - extremely helpful with debugging.

The alternative approach of layering a secondary data graph
with parameter and input-output ports on each behaviour was
discarded as being too heavy for the zen requirements of py_trees.
This is in part due to the wiring costs, but also due to
complexity arising from a tree's partial graph execution
(a feature which makes trees different from most computational
graph frameworks) and not to regress on py_trees' capability to
dynamically insert and prune subtrees on the fly.

A high-level list of existing / planned features:

* [+] Centralised key-value store
* [+] Client connections with namespaced read/write access to the store
* [+] Integration with behaviours for key-behaviour associations (debugging)
* [+] Activity stream that logs read/write operations by clients
* [+] Exclusive locks for writing
* [+] Framework for key remappings

.. include:: weblinks.rst
"""

##############################################################################
# Imports
##############################################################################

import enum
import itertools
import operator
import re
import typing
import uuid

from . import common
from . import console
from . import utilities

##############################################################################
# Classes
##############################################################################


class KeyMetaData(object):
    """
    Stores the aggregated metadata for a key on the blackboard.
    """
    def __init__(self):
        self.read = set()
        self.write = set()
        self.exclusive = set()


class ActivityType(enum.Enum):
    """An enumerator representing the operation on a blackboard variable"""

    READ = "READ"
    """Read from the blackboard"""
    INITIALISED = "INITIALISED"
    """Initialised a key-value pair on the blackboard"""
    WRITE = "WRITE"
    """Wrote to the blackboard."""
    ACCESSED = "ACCESSED"
    """Key accessed, either for reading, or modification of the value's internal attributes (e.g. foo.bar)."""
    ACCESS_DENIED = "ACCESS_DENIED"
    """Client did not have access to read/write a key."""
    NO_KEY = "NO_KEY"
    """Tried to access a key that does not yet exist on the blackboard."""
    NO_OVERWRITE = "NO_OVERWRITE"
    """Tried to write but variable already exists and a no-overwrite request was respected."""
    UNSET = "UNSET"
    """Key was removed from the blackboard"""


class ActivityItem(object):
    """
    Recorded data pertaining to activity on the blackboard.

    Args:
        key: name of the variable on the blackboard
        client_name: convenient name of the client performing the operation
        client_id: unique id of the client performing the operation
        activity_type: type of activity
        previous_value: of the given key (None if this field is not relevant)
        current_value: current value for the given key (None if this field is not relevant)
    """
    def __init__(
            self,
            key,
            client_name: str,
            client_id: uuid.UUID,
            activity_type: str,
            previous_value: typing.Any=None,
            current_value: typing.Any=None):
        # TODO validity checks for values passed/not passed on the
        # respective activity types. Note: consider using an enum
        # for 'no value here' since None is a perfectly acceptable
        # value for a key
        self.key = key
        self.client_name = client_name
        self.client_id = client_id
        self.activity_type = activity_type
        self.previous_value = previous_value
        self.current_value = current_value


class ActivityStream(object):
    """
    Storage container with convenience methods for manipulating the stored
    activity stream.

    Attributes:
        data (typing.List[ActivityItem]: list of activity items, earliest first
        maximum_size (int): pop items if this size is exceeded
    """

    def __init__(self, maximum_size: int=500):
        """
        Initialise the stream with a maximum storage limit.

        Args:
            maximum_size: pop items from the stream if this size is exceeded
        """
        self.data: typing.List[ActivityItem] = []
        self.maximum_size = maximum_size

    def push(self, activity_item: ActivityItem):
        """
        Push the next activity item to the stream.

        Args:
            activity_item: new item to append to the stream
        """
        if len(self.data) > self.maximum_size:
            self.data.pop()
        self.data.append(activity_item)

    def clear(self):
        """
        Delete all activities from the stream.
        """
        self.data = []


class Blackboard(object):
    """
    Centralised key-value store for sharing data between behaviours.
    This class is a coat-hanger for the centralised data store, metadata
    for it's administration and static methods for interacting with it.

    This api is intended for authors of debugging and introspection
    tools on the blackboard. Users should make use of the :class:`Client`.

    Attributes:
        Blackboard.clients (typing.Dict[uuid.UUID, str]): client uuid-name registry
        Blackboard.storage (typing.Dict[str, typing.Any]): key-value data store
        Blackboard.metadata (typing.Dict[str, KeyMetaData]): key associated metadata
        Blackboard.activity_stream (ActivityStream): logged activity
        Blackboard.separator (char): namespace separator character
    """
    storage: typing.Dict[str, typing.Any] = {}  # key-value storage
    metadata: typing.Dict[str, KeyMetaData] = {}  # key-metadata information
    clients: typing.Dict[uuid.UUID, str] = {}   # client id-name pairs
    activity_stream: typing.Optional[ActivityStream] = None
    separator: str = "/"

    @staticmethod
    def keys() -> typing.Set[str]:
        """
        Get the set of blackboard keys.

        Returns:
            the complete set of keys registered by clients
        """
        # return registered keys, those on the blackboard are not
        # necessarily written to yet
        return set(Blackboard.metadata.keys())

    @staticmethod
    def get(variable_name: str) -> typing.Any:
        """
        Extract the value associated with the given a variable name,
        can be nested, e.g. battery.percentage. This differs from the
        client get method in that it doesn't pass through the client access
        checks. To be used for utility tooling (e.g. display methods) and not by
        users directly.

        Args:
            variable_name: of the variable to get, can be nested, e.g. battery.percentage

        Raises:
            KeyError: if the variable or it's nested attributes do not yet exist on the blackboard

        Return:
            The stored value for the given variable
        """
        variable_name = Blackboard.absolute_name(Blackboard.separator, variable_name)
        name_components = variable_name.split('.')
        key = name_components[0]
        key_attributes = '.'.join(name_components[1:])
        # can raise KeyError
        value = Blackboard.storage[key]
        if key_attributes:
            try:
                value = operator.attrgetter(key_attributes)(value)
            except AttributeError:
                raise KeyError("Key exists, but does not have the specified nested attributes [{}]".format(variable_name))
        return value

    @staticmethod
    def set(variable_name: str, value: typing.Any):
        """
        Set the value associated with the given a variable name,
        can be nested, e.g. battery.percentage. This differs from the
        client get method in that it doesn't pass through the client access
        checks. To be used for utility tooling (e.g. display methods) and not by
        users directly.

        Args:
            variable_name: of the variable to set, can be nested, e.g. battery.percentage

        Raises:
            AttributeError: if it is attempting to set a nested attribute tha does not exist.
        """
        variable_name = Blackboard.absolute_name(Blackboard.separator, variable_name)
        name_components = variable_name.split('.')
        key = name_components[0]
        key_attributes = '.'.join(name_components[1:])
        if not key_attributes:
            Blackboard.storage[key] = value
        else:
            setattr(Blackboard.storage[key], key_attributes, value)
        Blackboard.metadata.setdefault(key, KeyMetaData())

    @staticmethod
    def unset(key: str):
        """
        For when you need to completely remove a blackboard variable (key-value pair),
        this provides a convenient helper method.

        Args:
            key: name of the variable to remove

        Returns:
            True if the variable was removed, False if it was already absent
        """
        try:
            key = Blackboard.absolute_name(Blackboard.separator, key)
            del Blackboard.storage[key]
            return True
        except KeyError:
            return False

    @staticmethod
    def exists(name: str) -> bool:
        """
        Check if the specified variable exists on the blackboard.

        Args:
            name: name of the variable, can be nested, e.g. battery.percentage

        Raises:
            AttributeError: if the client does not have read access to the variable
        """
        try:
            name = Blackboard.absolute_name(Blackboard.separator, name)
            unused_value = Blackboard.get(name)
            return True
        except KeyError:
            return False

    @staticmethod
    def keys_filtered_by_regex(regex: str) -> typing.Set[str]:
        """
        Get the set of blackboard keys filtered by regex.

        Args:
            regex: a python regex string

        Returns:
            subset of keys that have been registered and match the pattern
        """
        pattern = re.compile(regex)
        return {key for key in Blackboard.metadata.keys() if pattern.search(key) is not None}

    @staticmethod
    def keys_filtered_by_clients(
        client_ids: typing.Union[typing.Set[uuid.UUID], typing.List[uuid.UUID]]
    ) -> typing.Set[str]:
        """
        Get the set of blackboard keys filtered by client unique identifiers.

        Args:
            client_ids: set of client uuid's.

        Returns:
            subset of keys that have been registered by the specified clients
        """
        # forgive users if they sent a list instead of a set
        if isinstance(client_ids, list):
            client_ids = set(client_ids)
        keys = set()
        for key in Blackboard.metadata.keys():
            # for sets, | is union, & is intersection
            key_clients = (
                set(Blackboard.metadata[key].read) |
                set(Blackboard.metadata[key].write) |
                set(Blackboard.metadata[key].exclusive)
            )
            if key_clients & client_ids:
                keys.add(key)
        return keys

    @staticmethod
    def enable_activity_stream(maximum_size: int=500):
        """
        Enable logging of activities on the blackboard.

        Args:
            maximum_size: pop items from the stream if this size is exceeded

        Raises:
            RuntimeError if the activity stream is already enabled
        """
        if Blackboard.activity_stream is None:
            Blackboard.activity_stream = ActivityStream(maximum_size)
        else:
            RuntimeError("activity stream is already enabled for this blackboard")

    @staticmethod
    def disable_activity_stream():
        """
        Disable logging of activities on the blackboard
        """
        Blackboard.activity_stream = None

    @staticmethod
    def clear():
        """
        Completely clear all key, value and client information from the blackboard.
        Also deletes the activity stream.
        """
        Blackboard.storage.clear()
        Blackboard.metadata.clear()
        Blackboard.clients.clear()
        Blackboard.activity_stream = None

    @staticmethod
    def absolute_name(namespace: str, key: str) -> str:
        """
        Generate the fully qualified key name from namespace and name arguments.

        **Examples**

        .. code-block:: python

            '/' + 'foo'  = '/foo'
            '/' + '/foo' = '/foo'
            '/foo' + 'bar' = '/foo/bar'
            '/foo/' + 'bar' = '/foo/bar'
            '/foo' + '/foo/bar' = '/foo/bar'
            '/foo' + '/bar' = '/bar'
            '/foo' + 'foo/bar' = '/foo/foo/bar'

        Args:
            namespace: namespace the key should be embedded in
            key: key name (relative or absolute)

        Returns:
            the absolute name

        .. warning::

            To expedite the method call (it's used with high frequency
            in blackboard key lookups), no checks are made to ensure
            the namespace argument leads with a "/". Nor does it check
            that a name in absolute form is actually embedded in the
            specified namespace, it just returns the given (absolute)
            name directly.
        """
        # it's already absolute
        if key.startswith(Blackboard.separator):
            return key
        # remove leading and trailing separators
        namespace = namespace if namespace.endswith(Blackboard.separator) else namespace + Blackboard.separator
        key = key.strip(Blackboard.separator)
        return "{}{}".format(namespace, key)

    @staticmethod
    def relative_name(namespace: str, key: str) -> str:
        """
        **Examples**

        .. code-block:: python

            '/' + 'foo'  = '/foo'
            '/' + '/foo' = '/foo'
            '/foo' + 'bar' = '/foo/bar'
            '/foo/' + 'bar' = '/foo/bar'
            '/foo' + '/bar' => KeyError('/bar' is not in 'foo')
            '/foo' + 'foo/bar' = '/foo/foo/bar'

        Args:
            namespace: namespace the key should be embedded in
            key: key name (relative or absolute)

        Returns:
            the absolute name

        Raises:
            KeyError if the key is not in the specified namespace

        .. warning::

            To expedite the method call (it's used with high frequency
            in blackboard key lookups), no checks are made to ensure
            the namespace argument leads with a "/"
        """
        # it's already relative
        if not key.startswith(Blackboard.separator):
            return key
        # remove leading and trailing separators
        namespace = namespace if namespace.endswith(Blackboard.separator) else namespace + Blackboard.separator
        if key.startswith(namespace):
            return key.lstrip(namespace)
        else:
            raise KeyError("key '{}' is not in namespace '{}'".format(
                key, namespace)
            )

    @staticmethod
    def key(variable_name: str) -> str:
        """
        Extract the key for an arbitrary blackboard variable, keeping in mind that blackboard variable
        names can be pointing to a nested attribute within a key.

        Example: '/foo/bar.woohoo -> /foo/bar'.

        Args:
            variable_name: blackboard variable name - can be nested, e.g. battery.percentage

        Returns:
            name of the underlying key
        """
        name_components = variable_name.split('.')
        key = name_components[0]
        return key

    @staticmethod
    def key_with_attributes(variable_name: str) -> typing.Tuple[str, str]:
        """
        Extract the key for an arbitrary blackboard variable, keeping in mind that blackboard variable
        names can be pointing to a nested attribute within a key,

        Example: '/foo/bar.woohoo -> (/foo/bar'. 'woohoo')

        Args:
            variable_name: blackboard variable name - can be nested, e.g. battery.percentage

        Returns:
            a tuple consisting of the key and it's attributes (in string form)
        """
        name_components = variable_name.split('.')
        key = name_components[0]
        key_attributes = '.'.join(name_components[1:])
        return (key, key_attributes)


class Client(object):
    """
    Client to the key-value store for sharing data between behaviours.

    **Examples**

    Blackboard clients will accept a user-friendly name or create one
    for you if none is provided. Regardless of what name is chosen, clients
    are always uniquely identified via a uuid generated on construction.

    .. code-block:: python

        provided = py_trees.blackboard.Client(name="Provided")
        print(provided)
        generated = py_trees.blackboard.Client()
        print(generated)

    .. figure:: images/blackboard_client_instantiation.png
       :align: center

       Client Instantiation

    Register read/write access for keys on the blackboard. Note, registration is
    not initialisation.

    .. code-block:: python

        blackboard = py_trees.blackboard.Client(name="Client")
        blackboard.register_key(key="foo", access=py_trees.common.Access.WRITE)
        blackboard.register_key(key="bar", access=py_trees.common.Access.READ)
        blackboard.foo = "foo"
        print(blackboard)

    .. figure:: images/blackboard_read_write.png
       :align: center

       Variable Read/Write Registration

    Keys and clients can make use of namespaces, designed by the '/' char. Most
    methods permit a flexible expression of either relative or absolute names.

    .. code-block:: python

        blackboard = py_trees.blackboard.Client(name="Global")
        parameters = py_trees.blackboard.Client(name="Parameters", namespace="parameters")

        blackboard.register_key(key="foo", access=py_trees.common.Access.WRITE)
        blackboard.register_key(key="/bar", access=py_trees.common.Access.WRITE)
        blackboard.register_key(key="/parameters/default_speed", access=py_trees.common.Access.WRITE)
        parameters.register_key(key="aggressive_speed", access=py_trees.common.Access.WRITE)

        blackboard.foo = "foo"
        blackboard.bar = "bar"
        blackboard.parameters.default_speed = 20.0
        parameters.aggressive_speed = 60.0

        miss_daisy = blackboard.parameters.default_speed
        van_diesel = parameters.aggressive_speed

        print(blackboard)
        print(parameters)

    .. figure:: images/blackboard_namespaces.png
       :align: center

       Namespaces and Namespaced Clients


    Disconnected instances will discover the centralised
    key-value store.

    .. code-block:: python

        def check_foo():
            blackboard = py_trees.blackboard.Client(name="Reader")
            blackboard.register_key(key="foo", access=py_trees.common.Access.READ)
            print("Foo: {}".format(blackboard.foo))


        blackboard = py_trees.blackboard.Client(name="Writer")
        blackboard.register_key(key="foo", access=py_trees.common.Access.WRITE)
        blackboard.foo = "bar"
        check_foo()

    To respect an already initialised key on the blackboard:

    .. code-block:: python

        blackboard = Client(name="Writer")
        blackboard.register_key(key="foo", access=py_trees.common.Access.READ)
        result = blackboard.set("foo", "bar", overwrite=False)

    Store complex objects on the blackboard:

    .. code-block:: python

        class Nested(object):
            def __init__(self):
                self.foo = None
                self.bar = None

            def __str__(self):
                return str(self.__dict__)


        writer = py_trees.blackboard.Client(name="Writer")
        writer.register_key(key="nested", access=py_trees.common.Access.WRITE)
        reader = py_trees.blackboard.Client(name="Reader")
        reader.register_key(key="nested", access=py_trees.common.Access.READ)

        writer.nested = Nested()
        writer.nested.foo = "I am foo"
        writer.nested.bar = "I am bar"

        foo = reader.nested.foo
        print(writer)
        print(reader)

    .. figure:: images/blackboard_nested.png
       :align: center

    Log and display the activity stream:

    .. code-block:: python

        py_trees.blackboard.Blackboard.enable_activity_stream(maximum_size=100)
        reader = py_trees.blackboard.Client(name="Reader")
        reader.register_key(key="foo", access=py_trees.common.Access.READ)
        writer = py_trees.blackboard.Client(name="Writer")
        writer.register_key(key="foo", access=py_trees.common.Access.WRITE)
        writer.foo = "bar"
        writer.foo = "foobar"
        unused_result = reader.foo
        print(py_trees.display.unicode_blackboard_activity_stream())
        py_trees.blackboard.Blackboard.activity_stream.clear()

    .. figure:: images/blackboard_activity_stream.png
       :align: center

    Display the blackboard on the console, or part thereof:

    .. code-block:: python

        writer = py_trees.blackboard.Client(name="Writer")
        for key in {"foo", "bar", "dude", "dudette"}:
            writer.register_key(key=key, access=py_trees.common.Access.WRITE)

        reader = py_trees.blackboard.Client(name="Reader")
        for key in {"foo", "bar"}:
            reader.register_key(key="key", access=py_trees.common.Access.READ)

        writer.foo = "foo"
        writer.bar = "bar"
        writer.dude = "bob"

        # all key-value pairs
        print(py_trees.display.unicode_blackboard())
        # various filtered views
        print(py_trees.display.unicode_blackboard(key_filter={"foo"}))
        print(py_trees.display.unicode_blackboard(regex_filter="dud*"))
        print(py_trees.display.unicode_blackboard(client_filter={reader.unique_identifier}))
        # list the clients associated with each key
        print(py_trees.display.unicode_blackboard(display_only_key_metadata=True))

    .. figure:: images/blackboard_display.png
       :align: center

    Behaviours are not automagically connected to the blackboard but you may
    manually attach one or more clients so that associations between behaviours
    and variables can be tracked - this is very useful for introspection and
    debugging.

    Creating a custom behaviour with blackboard variables:

    .. code-block:: python

        class Foo(py_trees.behaviour.Behaviour):

            def __init__(self, name):
                super().__init__(name=name)
                self.blackboard = self.attach_blackboard_client(name="Foo Global")
                self.parameters = self.attach_blackboard_client(name="Foo Params", namespace="foo_parameters_")
                self.state = self.attach_blackboard_client(name="Foo State", namespace="foo_state_")

                # create a key 'foo_parameters_init' on the blackboard
                self.parameters.register_key("init", access=py_trees.common.Access.READ)
                # create a key 'foo_state_number_of_noodles' on the blackboard
                self.state.register_key("number_of_noodles", access=py_trees.common.Access.WRITE)

            def initialise(self):
                self.state.number_of_noodles = self.parameters.init

            def update(self):
                self.state.number_of_noodles += 1
                self.feedback_message = self.state.number_of_noodles
                if self.state.number_of_noodles > 5:
                    return py_trees.common.Status.SUCCESS
                else:
                    return py_trees.common.Status.RUNNING


        # could equivalently do directly via the Blackboard static methods if
        # not interested in tracking / visualising the application configuration
        configuration = py_trees.blackboard.Client(name="App Config")
        configuration.register_key("foo_parameters_init", access=py_trees.common.Access.WRITE)
        configuration.foo_parameters_init = 3

        foo = Foo(name="The Foo")
        for i in range(1, 8):
            foo.tick_once()
            print("Number of Noodles: {}".format(foo.feedback_message))

    Rendering a dot graph for a behaviour tree, complete with blackboard variables:

    .. code-block:: python

        # in code
        py_trees.display.render_dot_tree(py_trees.demos.blackboard.create_root())
        # command line tools
        py-trees-render --with-blackboard-variables py_trees.demos.blackboard.create_root

    .. graphviz:: dot/demo-blackboard.dot
       :align: center
       :caption: Tree with Blackboard Variables

    And to demonstrate that it doesn't become a tangled nightmare at scale, an example of
    a more complex tree:

    .. graphviz:: dot/blackboard-with-variables.dot
       :align: center
       :caption: A more complex tree

    Debug deeper with judicious application of the tree, blackboard and activity stream
    display methods around the tree tick (refer to
    :class:`py_trees.visitors.DisplaySnapshotVisitor` for examplar code):

    .. figure:: images/blackboard_trees.png
       :align: center

       Tree level debugging

    .. seealso::

       * :ref:`py-trees-demo-blackboard <py-trees-demo-blackboard-program>`
       * :ref:`py-trees-demo-namespaces <py-trees-demo-blackboard-namespaces-program>`
       * :ref:`py-trees-demo-remappings <py-trees-demo-blackboard-remappings-program>`
       * :class:`py_trees.visitors.DisplaySnapshotVisitor`
       * :class:`py_trees.behaviours.SetBlackboardVariable`
       * :class:`py_trees.behaviours.UnsetBlackboardVariable`
       * :class:`py_trees.behaviours.CheckBlackboardVariableExists`
       * :class:`py_trees.behaviours.WaitForBlackboardVariable`
       * :class:`py_trees.behaviours.CheckBlackboardVariableValue`
       * :class:`py_trees.behaviours.WaitForBlackboardVariableValue`

    Attributes:
        name (str): client's convenient, but not necessarily unique identifier
        namespace (str): apply this as a prefix to any key/variable name operations
        unique_identifier (uuid.UUID): client's unique identifier
        read (typing.Set[str]): set of absolute key names with read access
        write (typing.Set[str]): set of absolute key names with write access
        exclusive (typing.Set[str]): set of absolute key names with exclusive write access
        required (typing.Set[str]): set of absolute key names required to have data present
        remappings (typing.Dict[str, str]: client key names with blackboard remappings
        namespaces (typing.Set[str]: a cached list of namespaces this client accesses
    """
    def __init__(
            self, *,
            name: str=None,
            namespace: str=None):
        """
        Args:
            name: client's convenient identifier (stringifies the uuid if None)
            namespace: prefix to apply to key/variable name operations
            read: list of keys for which this client has read access
            write: list of keys for which this client has write access
            exclusive: list of keys for which this client has exclusive write access

        Raises:
            TypeError: if the provided name is not of type str
            ValueError: if the unique identifier has already been registered
        """

        # unique identifier
        super().__setattr__("unique_identifier", uuid.uuid4())
        if super().__getattribute__("unique_identifier") in Blackboard.clients.keys():
            raise ValueError("this unique identifier has already been registered")

        # name
        if name is None or not name:
            name = utilities.truncate(
                original=str(super().__getattribute__("unique_identifier")).replace('-', '_'),
                length=7
            )
            super().__setattr__("name", name)
        else:
            if not isinstance(name, str):
                raise TypeError("provided name is not of type str [{}]".format(type(name)))
            super().__setattr__("name", name)

        # namespaces
        namespace = "" if namespace is None else namespace
        if not namespace.startswith(Blackboard.separator):
            namespace = Blackboard.separator + namespace
        super().__setattr__("namespace", namespace)
        super().__setattr__("namespaces", set())

        super().__setattr__("read", set())
        super().__setattr__("write", set())
        super().__setattr__("exclusive", set())
        super().__setattr__("required", set())
        super().__setattr__("remappings", {})
        Blackboard.clients[
            super().__getattribute__("unique_identifier")
        ] = self.name

    def id(self) -> uuid.UUID:
        """
        The unique identifier for this client.

        Returns:
            The uuid.UUID object
        """
        return super().__getattribute__("unique_identifier")

    def __setattr__(self, name: str, value: typing.Any):
        """
        Convenience attribute style referencing with checking against
        permissions.

        Raises:
            AttributeError: if the client does not have write access to the variable
        """
        # print("__setattr__ [{}][{}]".format(name, value))
        name = Blackboard.absolute_name(super().__getattribute__("namespace"), name)
        if (
            (name not in super().__getattribute__("write")) and
            (name not in super().__getattribute__("exclusive"))
        ):
            if Blackboard.activity_stream is not None:
                Blackboard.activity_stream.push(
                    self._generate_activity_item(name, ActivityType.ACCESS_DENIED)
                )
            raise AttributeError("client '{}' does not have write access to '{}'".format(self.name, name))
        remapped_name = super().__getattribute__("remappings")[name]
        if Blackboard.activity_stream is not None:
            if remapped_name in Blackboard.storage.keys():
                Blackboard.activity_stream.push(
                    self._generate_activity_item(
                        key=remapped_name,
                        activity_type=ActivityType.WRITE,
                        previous_value=Blackboard.storage[remapped_name],
                        current_value=value
                    )
                )
            else:
                Blackboard.activity_stream.push(
                    self._generate_activity_item(
                        key=remapped_name,
                        activity_type=ActivityType.INITIALISED,
                        current_value=value
                    )
                )
        Blackboard.storage[remapped_name] = value

    def __getattr__(self, name: str):
        """
        Convenience attribute style referencing with checking against
        permissions.

        Raises:
            AttributeError: if the client does not have read access to the variable
            KeyError: if the variable does not yet exist on the blackboard
        """
        # print("__getattr__ [{}]".format(name))
        name = Blackboard.absolute_name(super().__getattribute__("namespace"), name)
        read_key = False
        write_key = False
        if name in super().__getattribute__("read"):
            read_key = True
        elif name in super().__getattribute__("write"):
            write_key = True
        elif name in super().__getattribute__("exclusive"):
            write_key = True
        else:
            if name in super().__getattribute__("namespaces"):
                return IntermediateVariableFetcher(blackboard=self, namespace=name)
            if Blackboard.activity_stream is not None:
                Blackboard.activity_stream.push(
                    self._generate_activity_item(name, ActivityType.ACCESS_DENIED)
                )
            raise AttributeError("client '{}' does not have read/write access to '{}'".format(self.name, name))
        remapped_name = super().__getattribute__("remappings")[name]
        try:
            if write_key:
                if Blackboard.activity_stream is not None:
                    if utilities.is_primitive(Blackboard.storage[remapped_name]):
                        activity_type = ActivityType.READ
                    else:  # could be a nested class object being accessed to write an attribute
                        activity_type = ActivityType.ACCESSED
                    Blackboard.activity_stream.push(
                        self._generate_activity_item(
                            key=remapped_name,
                            activity_type=activity_type,
                            current_value=Blackboard.storage[remapped_name],
                        )
                    )
                return Blackboard.storage[remapped_name]
            if read_key:
                if Blackboard.activity_stream is not None:
                    Blackboard.activity_stream.push(
                        self._generate_activity_item(
                            key=remapped_name,
                            activity_type=ActivityType.READ,
                            current_value=Blackboard.storage[remapped_name],
                        )
                    )
                return Blackboard.storage[remapped_name]
        except KeyError as e:
            if Blackboard.activity_stream is not None:
                Blackboard.activity_stream.push(
                    self._generate_activity_item(remapped_name, ActivityType.NO_KEY)
                )
            raise KeyError("client '{}' tried to access '{}' but it does not yet exist on the blackboard".format(self.name, remapped_name)) from e

    def set(self, name: str, value: typing.Any, overwrite: bool=True) -> bool:
        """
        Set, conditionally depending on whether the variable already exists or otherwise.

        This is most useful when initialising variables and multiple elements
        seek to do so. A good policy to adopt for your applications in these situations is
        a first come, first served policy. Ensure global configuration has the first
        opportunity followed by higher priority behaviours in the tree and so forth.
        Lower priority behaviours would use this to respect the pre-configured
        setting and at most, just validate that it is acceptable to the functionality
        of it's own behaviour.

        Args:
            name: name of the variable to set
            value: value of the variable to set
            overwrite: do not set if the variable already exists on the blackboard

        Returns:
            success or failure (overwrite is False and variable already set)

        Raises:
            AttributeError: if the client does not have write access to the variable
            KeyError: if the variable does not yet exist on the blackboard
        """
        name = Blackboard.absolute_name(super().__getattribute__("namespace"), name)
        name_components = name.split('.')
        key = name_components[0]
        key_attributes = '.'.join(name_components[1:])
        if (
            (key not in super().__getattribute__("write")) and
            (key not in super().__getattribute__("exclusive"))
        ):
            if Blackboard.activity_stream is not None:
                Blackboard.activity_stream.push(
                    self._generate_activity_item(key, ActivityType.ACCESS_DENIED)
                )
            raise AttributeError("client '{}' does not have write access to '{}'".format(self.name, name))
        remapped_key = super().__getattribute__("remappings")[key]
        if not overwrite:
            if remapped_key in Blackboard.storage:
                if Blackboard.activity_stream is not None:
                    Blackboard.activity_stream.push(
                        self._generate_activity_item(
                            key=remapped_key,
                            activity_type=ActivityType.NO_OVERWRITE,
                            current_value=Blackboard.storage[remapped_key])
                    )
                return False
        if not key_attributes:
            setattr(self, key, value)
            return True
        else:
            blackboard_object = getattr(self, key)
            try:
                setattr(blackboard_object, key_attributes, value)
                return True
            except AttributeError:  # when the object doesn't have the attributes
                return False

    def exists(self, name: str) -> bool:
        """
        Check if the specified variable exists on the blackboard.

        Args:
            name: name of the variable to get, can be nested, e.g. battery.percentage

        Raises:
            AttributeError: if the client does not have read access to the variable
        """
        try:
            unused_value = self.get(name)
            return True
        except KeyError:
            return False

    def absolute_name(self, key: str) -> str:
        """
        Generate the fully qualified key name for this key.

        .. code-block:: python

            blackboard = Client(name="FooBar", namespace="foo")
            blackboard.register_key(key="bar", access=py_trees.common.Access.READ)
            print("{}".format(blackboard.absolute_name("bar")))  # "/foo/bar"

        Args:
            key: name of the key

        Returns:
            the absolute name

        Raises:
            KeyError: if the key is not registered with this client
        """
        if not self.is_registered(key=key):
            raise KeyError("key '{}' is not in namespace '{}'".format(
                key, super().__getattribute__("namespace"))
            )
        return Blackboard.absolute_name(
            super().__getattribute__("namespace"),
            key
        )

    def get(self, name: str) -> typing.Any:
        """
        Method based accessor to the blackboard variables (as opposed to simply using
        '.<name>').

        Args:
            name: name of the variable to get, can be nested, e.g. battery.percentage

        Raises:
            AttributeError: if the client does not have read access to the variable
            KeyError: if the variable or it's nested attributes do not yet exist on the blackboard
        """
        # key attributes is an empty string if not a nested variable name
        name_components = name.split('.')
        key = name_components[0]
        key_attributes = '.'.join(name_components[1:])
        value = getattr(self, key)  # will run through client access checks in __getattr__
        if key_attributes:
            try:
                value = operator.attrgetter(key_attributes)(value)
            except AttributeError:
                raise KeyError("Key exists, but does not have the specified nested attributes [{}]".format(name))
        return value

    def unset(self, key: str):
        """
        For when you need to completely remove a blackboard variable (key-value pair),
        this provides a convenient helper method.

        Args:
            key: name of the variable to remove

        Returns:
            True if the variable was removed, False if it was already absent
        """
        key = Blackboard.absolute_name(super().__getattribute__("namespace"), key)
        remapped_key = super().__getattribute__("remappings")[key]
        if Blackboard.activity_stream is not None:
            Blackboard.activity_stream.push(
                self._generate_activity_item(remapped_key, ActivityType.UNSET)
            )
        # Three means of handling a non-existent key - 1) raising a KeyError, 2) catching
        # the KeyError and passing, 3) catch the KeyError and return True/False.
        # Option 1) is inconvenient - requires a redundant try/catch 99% of cases
        # Option 2) hides information - bad
        # Option 3) no extra code necessary and information is there if desired
        try:
            del Blackboard.storage[remapped_key]
            return True
        except KeyError:
            return False

    def _generate_activity_item(self, key, activity_type, previous_value=None, current_value=None):
        return ActivityItem(
            key=key,
            client_name=super().__getattribute__("name"),
            client_id=super().__getattribute__("unique_identifier"),
            # use strings here, so displaying the streams is agnostic of the enum
            activity_type=activity_type.value,
            previous_value=previous_value,
            current_value=current_value
        )

    def _update_namespaces(self, added_key=None):
        """
        Update the namespace cache.

        Args:
            added_key: hint on the most recent operation to enable an smart check/rebuild
        """
        if added_key is not None:
            namespace = added_key.rsplit("/", 1)[0]
            while namespace:
                super().__getattribute__("namespaces").add(namespace)
                namespace = namespace.rsplit("/", 1)[0]
        else:
            # completely rebuild
            super().__getattribute__("namespaces").clear()
            for key in itertools.chain(
                super().__getattribute__("read"),
                super().__getattribute__("write"),
                super().__getattribute__("exclusive")
            ):
                namespace = key.rsplit("/", 1)[0]
                while namespace:
                    super().__getattribute__("namespaces").add(namespace)
                    namespace = namespace.rsplit("/", 1)[0]

    def __str__(self):
        indent = "  "
        s = console.green + "Blackboard Client" + console.reset + "\n"
        s += console.white + indent + "Client Data" + console.reset + "\n"
        keys = ["name", "namespace", "unique_identifier", "read", "write", "exclusive"]
        s += self._stringify_key_value_pairs(keys, self.__dict__, 2 * indent)
        keys = {k for k, v in self.remappings.items() if k != v}
        if keys:
            s += console.white + indent + "Remappings" + console.reset + "\n"
            s += self._stringify_key_value_pairs(
                keys=keys,
                key_value_dict=self.remappings,
                indent=2 * indent,
                separator=console.right_arrow
            )
        s += console.white + indent + "Variables" + console.reset + "\n"
        keys = self.remappings.values()
        s += self._stringify_key_value_pairs(keys, Blackboard.storage, 2 * indent)
        return s

    def _stringify_key_value_pairs(self, keys, key_value_dict, indent, separator=":"):
        s = ""
        max_length = 0
        for key in keys:
            max_length = len(key) if len(key) > max_length else max_length
        for key in keys:
            try:
                value = key_value_dict[key]
                lines = ('{0}'.format(value)).split('\n')
                if len(lines) > 1:
                    s += console.cyan + indent + '{0: <{1}}'.format(key, max_length + 1) + console.reset + separator + "\n"
                    for line in lines:
                        s += console.yellow + indent + "  {0}\n".format(line) + console.reset
                else:
                    s += console.cyan + indent + '{0: <{1}}'.format(key, max_length + 1) + console.reset + separator + " " + console.yellow + '{0}\n'.format(value) + console.reset
            except KeyError:
                s += console.cyan + indent + '{0: <{1}}'.format(key, max_length + 1) + console.reset + separator + " " + console.yellow + "-\n" + console.reset
        s += console.reset
        return s

    def unregister(self, clear: bool=True):
        """
        Unregister this blackboard client and if requested, clear key-value pairs if this
        client is the last user of those variables.

        Args:
            clear: remove key-values pairs from the blackboard
        """
        self.unregister_all_keys(clear)
        del Blackboard.clients[super().__getattribute__("unique_identifier")]

    def unregister_all_keys(self, clear: bool=True):
        """
        Unregister all keys currently registered by this blackboard client and if requested,
        clear key-value pairs if this client is the last user of those variables.

        Args:
            clear: remove key-values pairs from the blackboard
        """
        for key in itertools.chain(set(self.read), set(self.write), set(self.exclusive)):
            self.unregister_key(key=key, clear=clear, update_namespace_cache=False)
        self._update_namespaces()

    def verify_required_keys_exist(self):
        """
        En-masse check of existence on the blackboard for all keys that were hitherto
        registered as 'required'.

        Raises: KeyError if any of the required keys do not exist on the blackboard
        """
        absent = set()
        for key in super().__getattribute__("required"):
            if not self.exists(key):
                absent.add(key)
        if absent:
            raise KeyError("keys required, but not yet on the blackboard [{}]".format(absent))

    def is_registered(
        self,
        key: str,
        access: typing.Union[None, common.Access]=None
    ) -> bool:
        """
        Check to see if the specified key is registered.

        Args:
           key: in either relative or absolute form
           access: access property, if None, just checks for registration, regardless of property

        Returns:
           if registered, True otherwise False
        """
        absolute_name = Blackboard.absolute_name(
            super().__getattribute__("namespace"),
            key
        )
        if access == common.Access.READ:
            return absolute_name in self.read
        elif access == common.Access.WRITE:
            return absolute_name in self.write
        elif access == common.Access.EXCLUSIVE_WRITE:
            return absolute_name in self.exclusive
        else:
            return absolute_name in self.read | self.write | self.exclusive

    def register_key(
            self,
            key: str,
            access: common.Access,
            required: bool=False,
            remap_to: str=None,
    ):
        """
        Register a key on the blackboard to associate with this client.

        Args:
            key: key to register
            access: access level (read, write, exclusive write)
            required: if true, check key exists when calling
                :meth:`~verify_required_keys_exist`
            remap_to: remap the key to this location on the blackboard

        Note the remap simply changes the storage location. From the perspective of
        the client, access via the specified 'key' remains the same.

        Raises:
            AttributeError if exclusive write access is requested, but write access has already been given to another client
            TypeError if the access argument is of incorrect type
        """
        key = Blackboard.absolute_name(super().__getattribute__("namespace"), key)
        super().__getattribute__("remappings")[key] = key if remap_to is None else remap_to
        remapped_key = super().__getattribute__("remappings")[key]
        if access == common.Access.READ:
            super().__getattribute__("read").add(key)
            Blackboard.metadata.setdefault(remapped_key, KeyMetaData())
            Blackboard.metadata[remapped_key].read.add(super().__getattribute__("unique_identifier"))
        elif access == common.Access.WRITE:
            conflicts = set()
            try:
                for unique_identifier in Blackboard.metadata[remapped_key].exclusive:
                    conflicts.add(Blackboard.clients[unique_identifier])
                    if conflicts:
                        raise AttributeError("'{}' requested write on key '{}', but this key already associated with an exclusive writer[{}]".format(
                            super().__getattribute__("name"),
                            remapped_key,
                            conflicts)
                        )
            except KeyError:
                pass  # no readers or writers on the key yet
            super().__getattribute__("write").add(key)
            Blackboard.metadata.setdefault(remapped_key, KeyMetaData())
            Blackboard.metadata[remapped_key].write.add(super().__getattribute__("unique_identifier"))
        elif access == common.Access.EXCLUSIVE_WRITE:
            try:
                key_metadata = Blackboard.metadata[remapped_key]
                conflicts = set()
                for unique_identifier in (key_metadata.write | key_metadata.exclusive):
                    conflicts.add(Blackboard.clients[unique_identifier])
                if conflicts:
                    raise AttributeError("'{}' requested exclusive write on key '{}', but this key is already associated [{}]".format(
                        super().__getattribute__("name"),
                        remapped_key,
                        conflicts)
                    )
            except KeyError:
                pass  # no readers or writers on the key yet
            super().__getattribute__("exclusive").add(key)
            Blackboard.metadata.setdefault(remapped_key, KeyMetaData())
            Blackboard.metadata[remapped_key].exclusive.add(super().__getattribute__("unique_identifier"))
        else:
            raise TypeError("access argument is of incorrect type [{}]".format(type(access)))
        if required:
            super().__getattribute__("required").add(key)
        self._update_namespaces(added_key=key)

    def unregister_key(
            self,
            key: str,
            clear: bool=True,
            update_namespace_cache: bool=True):
        """
        Unegister a key associated with this client.

        Args:
            key: key to unregister
            clear: remove key-values pairs from the blackboard
            update_namespace_cache: disable if you are batching

        A method that batches calls to this method is :meth:`unregister_all_keys()`.

        Raises:
            KeyError if the key has not been previously registered
        """
        key = Blackboard.absolute_name(super().__getattribute__("namespace"), key)
        remapped_key = super().__getattribute__("remappings")[key]
        super().__getattribute__("read").discard(key)  # doesn't throw exceptions if it not present
        super().__getattribute__("write").discard(key)
        super().__getattribute__("exclusive").discard(key)
        Blackboard.metadata[remapped_key].read.discard(super().__getattribute__("unique_identifier"))
        Blackboard.metadata[remapped_key].write.discard(super().__getattribute__("unique_identifier"))
        Blackboard.metadata[remapped_key].exclusive.discard(super().__getattribute__("unique_identifier"))
        if (
            (not Blackboard.metadata[remapped_key].read) and
            (not Blackboard.metadata[remapped_key].write) and
            (not Blackboard.metadata[remapped_key].exclusive)
        ):
            del Blackboard.metadata[remapped_key]
            if clear:
                try:
                    del Blackboard.storage[remapped_key]
                except KeyError:
                    pass  # perfectly legitimate for a registered key to not exist on the blackboard
        del super().__getattribute__("remappings")[key]
        if update_namespace_cache:
            self._update_namespaces()


class IntermediateVariableFetcher(object):
    def __init__(self, blackboard, namespace):
        super().__setattr__("blackboard", blackboard)
        super().__setattr__("namespace", namespace)

    def __getattr__(self, name: str):
        # print("Fetcher:__getattr__ [{}]".format(name))
        name = Blackboard.absolute_name(self.namespace, name)
        return self.blackboard.get(name)

    def __setattr__(self, name: str, value: typing.Any):
        # print("Fetcher:__setattr__ [{}][{}]".format(name, value))
        name = Blackboard.absolute_name(self.namespace, name)
        return self.blackboard.set(name, value)
