"""
Module to interact with the ChromeCast via protobuf-over-socket.

Big thanks goes out to Fred Clift <fred@clift.org> who build the first
version of this code: https://github.com/minektur/chromecast-python-poc.
Without him this would not have been possible.
"""
# Pylint does not understand the protobuf objects correctly
# pylint: disable=no-member

import logging
import select
import socket
import ssl
import json
import threading
import time
from collections import namedtuple
from struct import pack, unpack
import sys

from . import cast_channel_pb2
from .dial import CAST_TYPE_CHROMECAST, CAST_TYPE_AUDIO, CAST_TYPE_GROUP
from .controllers import BaseController
from .controllers.media import MediaController
from .error import (
    ChromecastConnectionError,
    UnsupportedNamespace,
    NotConnected,
    PyChromecastStopped,
    LaunchError,
)

NS_CONNECTION = 'urn:x-cast:com.google.cast.tp.connection'
NS_RECEIVER = 'urn:x-cast:com.google.cast.receiver'
NS_HEARTBEAT = 'urn:x-cast:com.google.cast.tp.heartbeat'

PLATFORM_DESTINATION_ID = "receiver-0"

MESSAGE_TYPE = 'type'
TYPE_PING = "PING"
TYPE_RECEIVER_STATUS = "RECEIVER_STATUS"
TYPE_PONG = "PONG"
TYPE_CONNECT = "CONNECT"
TYPE_CLOSE = "CLOSE"
TYPE_GET_STATUS = "GET_STATUS"
TYPE_LAUNCH = "LAUNCH"
TYPE_LAUNCH_ERROR = "LAUNCH_ERROR"
TYPE_LOAD = "LOAD"

# The socket connection is being setup
CONNECTION_STATUS_CONNECTING = "CONNECTING"
# The socket connection was complete
CONNECTION_STATUS_CONNECTED = "CONNECTED"
# The socket connection has been disconnected
CONNECTION_STATUS_DISCONNECTED = "DISCONNECTED"
# Connecting to socket failed (after a CONNECTION_STATUS_CONNECTING)
CONNECTION_STATUS_FAILED = "FAILED"
# The socket connection was lost and needs to be retried
CONNECTION_STATUS_LOST = "LOST"

APP_ID = 'appId'
REQUEST_ID = "requestId"
SESSION_ID = "sessionId"
ERROR_REASON = 'reason'

HB_PING_TIME = 10
HB_PONG_TIME = 10
POLL_TIME_BLOCKING = 5.0
POLL_TIME_NON_BLOCKING = 0.01
TIMEOUT_TIME = 30
RETRY_TIME = 5


class InterruptLoop(Exception):
    """ The chromecast has been manually stopped. """
    pass


def _json_from_message(message):
    """ Parses a PB2 message into JSON format. """
    try:
        return json.loads(message.payload_utf8)
    except ValueError:
        logger = logging.getLogger(__name__)
        logger.warning("Ignoring invalid json in namespace %s: %s",
                       message.namespace, message.payload_utf8)
        return {}


def _message_to_string(message, data=None):
    """ Gives a string representation of a PB2 message. """
    if data is None:
        data = _json_from_message(message)

    return "Message {} from {} to {}: {}".format(
        message.namespace, message.source_id, message.destination_id, data)


if sys.version_info >= (3, 0):
    def _json_to_payload(data):
        """ Encodes a python value into JSON format. """
        return json.dumps(data, ensure_ascii=False).encode("utf8")
else:
    def _json_to_payload(data):
        """ Encodes a python value into JSON format. """
        return json.dumps(data, ensure_ascii=False)


def _is_ssl_timeout(exc):
    """ Returns True if the exception is for an SSL timeout """
    return exc.message in ("The handshake operation timed out",
                           "The write operation timed out",
                           "The read operation timed out")


NetworkAddress = namedtuple('NetworkAddress',
                            ['address', 'port'])


ConnectionStatus = namedtuple('ConnectionStatus',
                              ['status', 'address'])


CastStatus = namedtuple('CastStatus',
                        ['is_active_input', 'is_stand_by', 'volume_level',
                         'volume_muted', 'app_id', 'display_name',
                         'namespaces', 'session_id', 'transport_id',
                         'status_text'])


LaunchFailure = namedtuple('LaunchStatus',
                           ['reason', 'app_id', 'request_id'])


# pylint: disable=too-many-instance-attributes
class SocketClient(threading.Thread):
    """
    Class to interact with a Chromecast through a socket.

    :param port: The port to use when connecting to the device, set to None to
                 use the default of 8009. Special devices such as Cast Groups
                 may return a different port number so we need to use that.
    :param cast_type: The type of chromecast to connect to, see
                      dial.CAST_TYPE_* for types.
    :param tries: Number of retries to perform if the connection fails.
                  None for inifinite retries.
    :param retry_wait: A floating point number specifying how many seconds to
                       wait between each retry. None means to use the default
                       which is 5 seconds.
    """

    def __init__(self, host, port=None, cast_type=CAST_TYPE_CHROMECAST,
                 **kwargs):
        tries = kwargs.pop('tries', None)
        timeout = kwargs.pop('timeout', None)
        retry_wait = kwargs.pop('retry_wait', None)
        self.blocking = kwargs.pop('blocking', True)

        if self.blocking:
            self.polltime = POLL_TIME_BLOCKING
        else:
            self.polltime = POLL_TIME_NON_BLOCKING

        super(SocketClient, self).__init__()

        self.daemon = True

        self.logger = logging.getLogger(__name__)

        self._force_recon = False

        self.cast_type = cast_type
        self.tries = tries
        self.timeout = timeout or TIMEOUT_TIME
        self.retry_wait = retry_wait or RETRY_TIME
        self.host = host
        self.port = port or 8009

        self.source_id = "sender-0"
        self.stop = threading.Event()

        self.app_namespaces = []
        self.destination_id = None
        self.session_id = None
        self._request_id = 0
        # dict mapping requestId on threading.Event objects
        self._request_callbacks = {}
        self._open_channels = []

        self.connecting = True
        self.socket = None

        # dict mapping namespace on Controller objects
        self._handlers = {}
        self._connection_listeners = []

        self.receiver_controller = ReceiverController(cast_type, self.blocking)
        self.media_controller = MediaController()
        self.heartbeat_controller = HeartbeatController()

        self.register_handler(self.heartbeat_controller)
        self.register_handler(ConnectionController())
        self.register_handler(self.receiver_controller)
        self.register_handler(self.media_controller)

        self.receiver_controller.register_status_listener(self)

        try:
            self.initialize_connection()
        except ChromecastConnectionError:
            self._report_connection_status(
                ConnectionStatus(CONNECTION_STATUS_DISCONNECTED,
                                 NetworkAddress(self.host, self.port)))
            raise

    def initialize_connection(self):
        """Initialize a socket to a Chromecast, retrying as necessary."""
        tries = self.tries

        if self.socket is not None:
            self.socket.close()
            self.socket = None

        # Make sure nobody is blocking.
        for callback in self._request_callbacks.values():
            callback['event'].set()

        self.app_namespaces = []
        self.destination_id = None
        self.session_id = None
        self._request_id = 0
        self._request_callbacks = {}
        self._open_channels = []

        self.connecting = True
        retry_log_fun = self.logger.error

        while not self.stop.is_set() and (tries is None or tries > 0):
            try:
                self.socket = ssl.wrap_socket(socket.socket())
                self.socket.settimeout(self.timeout)
                self._report_connection_status(
                    ConnectionStatus(CONNECTION_STATUS_CONNECTING,
                                     NetworkAddress(self.host, self.port)))
                self.socket.connect((self.host, self.port))
                self.connecting = False
                self._force_recon = False
                self._report_connection_status(
                    ConnectionStatus(CONNECTION_STATUS_CONNECTED,
                                     NetworkAddress(self.host, self.port)))
                self.receiver_controller.update_status()
                self.heartbeat_controller.ping()
                self.heartbeat_controller.reset()

                self.logger.debug("Connected!")
                break
            except socket.error as err:
                self.connecting = True
                if self.stop.is_set():
                    self.logger.error(
                        "Failed to connect: %s. aborting due to stop signal.",
                        err)
                    raise ChromecastConnectionError("Failed to connect")

                self._report_connection_status(
                    ConnectionStatus(CONNECTION_STATUS_FAILED,
                                     NetworkAddress(self.host, self.port)))
                retry_log_fun("Failed to connect, retrying in %.1fs",
                              self.retry_wait)
                retry_log_fun = self.logger.debug

                time.sleep(self.retry_wait)
                if tries:
                    tries -= 1
        else:
            self.stop.set()
            self.logger.error("Failed to connect. No retries.")
            raise ChromecastConnectionError("Failed to connect")

    def disconnect(self):
        """ Disconnect socket connection to Chromecast device """
        self.stop.set()

    def register_handler(self, handler):
        """ Register a new namespace handler. """
        self._handlers[handler.namespace] = handler

        handler.registered(self)

    def new_cast_status(self, cast_status):
        """ Called when a new cast status has been received. """
        new_channel = self.destination_id != cast_status.transport_id

        if new_channel:
            self._disconnect_channel(self.destination_id)

        self.app_namespaces = cast_status.namespaces
        self.destination_id = cast_status.transport_id
        self.session_id = cast_status.session_id

        if new_channel:
            # If any of the namespaces of the new app are supported
            # we will automatically connect to it to receive updates
            for namespace in self.app_namespaces:
                if namespace in self._handlers:
                    self._ensure_channel_connected(self.destination_id)
                    self._handlers[namespace].channel_connected()

    def _gen_request_id(self):
        """ Generates a unique request id. """
        self._request_id += 1

        return self._request_id

    @property
    def is_connected(self):
        """
        Returns True if the client is connected, False if it is stopped
        (or trying to connect).
        """
        return not self.connecting

    @property
    def is_stopped(self):
        """
        Returns True if the connection has been stopped, False if it is
        running.
        """
        return self.stop.is_set()

    def run(self):
        """ Start polling the socket. """
        self.heartbeat_controller.reset()
        self._force_recon = False
        logging.debug("Thread started...")
        while not self.stop.is_set():

            if self.run_once() == 1:
                break

        # Clean up
        self._cleanup()

    def run_once(self):
        """
        Use run_once() in your own main loop after you
        receive something on the socket (get_socket()).
        """
        # pylint: disable=too-many-branches

        try:
            if not self._check_connection():
                return 0
        except ChromecastConnectionError:
            return 1

        # poll the socket
        can_read, _, _ = select.select([self.socket], [], [], self.polltime)

        # read messages from chromecast
        message = data = None
        if self.socket in can_read and not self._force_recon:
            try:
                message = self._read_message()
            except InterruptLoop as exc:
                if self.stop.is_set():
                    self.logger.info(
                        "Stopped while reading message, disconnecting.")
                else:
                    self.logger.error(
                        "Interruption caught without being stopped: %s",
                        exc)
                return 1
            except ssl.SSLError as exc:
                if exc.errno == ssl.SSL_ERROR_EOF:
                    if self.stop.is_set():
                        return 1
                raise
            except socket.error:
                self._force_recon = True
                self.logger.error('Error reading from socket.')
            else:
                data = _json_from_message(message)
        if not message:
            return 0

        # If we are stopped after receiving a message we skip the message
        # and tear down the connection
        if self.stop.is_set():
            return 1

        # See if any handlers will accept this message
        self._route_message(message, data)

        if REQUEST_ID in data:
            callback = self._request_callbacks.pop(data[REQUEST_ID], None)
            if callback is not None:
                event = callback['event']
                callback['response'] = data
                function = callback['function']
                event.set()
                if function:
                    function(data)

    def get_socket(self):
        """
        Returns the socket of the connection to use it in you own
        main loop.
        """
        return self.socket

    def _check_connection(self):
        """
        Checks if the connection is active, and if not reconnect

        :return: True if the connection is active, False if the connection was
                 reset.
        """
        # check if connection is expired
        reset = False
        if self._force_recon:
            self.logger.warning(
                "Error communicating with socket, resetting connection")
            reset = True

        elif self.heartbeat_controller.is_expired():
            self.logger.warning("Heartbeat timeout, resetting connection")
            reset = True

        if reset:
            self._report_connection_status(
                ConnectionStatus(CONNECTION_STATUS_LOST,
                                 NetworkAddress(self.host, self.port)))
            try:
                self.initialize_connection()
            except ChromecastConnectionError:
                self.stop.set()
            return False
        return True

    def _route_message(self, message, data):
        """ Route message to any handlers on the message namespace """
        # route message to handlers
        if message.namespace in self._handlers:

            # debug messages
            if message.namespace != NS_HEARTBEAT:
                self.logger.debug(
                    "Received: %s", _message_to_string(message, data))

            # message handlers
            try:
                handled = \
                    self._handlers[message.namespace].receive_message(
                        message, data)

                if not handled:
                    if data.get(REQUEST_ID) not in self._request_callbacks:
                        self.logger.debug(
                            "Message unhandled: %s",
                            _message_to_string(message, data))
            except Exception:  # pylint: disable=broad-except
                self.logger.exception(
                    (u"Exception caught while sending message to "
                     u"controller %s: %s"),
                    type(self._handlers[message.namespace]).__name__,
                    _message_to_string(message, data))

        else:
            self.logger.debug(
                "Received unknown namespace: %s",
                _message_to_string(message, data))

    def _cleanup(self):
        """ Cleanup open channels and handlers """
        for channel in self._open_channels:
            try:
                self._disconnect_channel(channel)
            except Exception:  # pylint: disable=broad-except
                pass

        for handler in self._handlers.values():
            try:
                handler.tear_down()
            except Exception:  # pylint: disable=broad-except
                pass

        self.socket.close()
        self._report_connection_status(
            ConnectionStatus(CONNECTION_STATUS_DISCONNECTED,
                             NetworkAddress(self.host, self.port)))
        self.connecting = True

    def _report_connection_status(self, status):
        """ Report a change in the connection status to any listeners """
        for listener in self._connection_listeners:
            try:
                self.logger.debug("connection listener: %x (%s)",
                                  id(listener), type(listener).__name__)
                listener.new_connection_status(status)
            except Exception:  # pylint: disable=broad-except
                pass

    def _read_bytes_from_socket(self, msglen):
        """ Read bytes from the socket. """
        chunks = []
        bytes_recd = 0
        while bytes_recd < msglen:
            if self.stop.is_set():
                raise InterruptLoop("Stopped while reading from socket")
            try:
                chunk = self.socket.recv(min(msglen - bytes_recd, 2048))
                if chunk == b'':
                    raise socket.error("socket connection broken")
                chunks.append(chunk)
                bytes_recd += len(chunk)
            except socket.timeout:
                continue
            except ssl.SSLError as exc:
                # Support older ssl implementations which does not raise
                # socket.timeout on timeouts
                if _is_ssl_timeout(exc):
                    continue
                raise
        return b''.join(chunks)

    def _read_message(self):
        """ Reads a message from the socket and converts it to a message. """
        # first 4 bytes is Big-Endian payload length
        payload_info = self._read_bytes_from_socket(4)
        read_len = unpack(">I", payload_info)[0]

        # now read the payload
        payload = self._read_bytes_from_socket(read_len)

        # pylint: disable=no-member
        message = cast_channel_pb2.CastMessage()
        message.ParseFromString(payload)

        return message

    # pylint: disable=too-many-arguments
    def send_message(self, destination_id, namespace, data,
                     inc_session_id=False, callback_function=False,
                     no_add_request_id=False, force=False):
        """ Send a message to the Chromecast. """

        # namespace is a string containing namespace
        # data is a dict that will be converted to json
        # wait_for_response only works if we have a request id

        # If channel is not open yet, connect to it.
        self._ensure_channel_connected(destination_id)

        request_id = None
        if not no_add_request_id:
            request_id = self._gen_request_id()
            data[REQUEST_ID] = request_id

        if inc_session_id:
            data[SESSION_ID] = self.session_id

        # pylint: disable=no-member
        msg = cast_channel_pb2.CastMessage()

        msg.protocol_version = msg.CASTV2_1_0
        msg.source_id = self.source_id
        msg.destination_id = destination_id
        msg.payload_type = cast_channel_pb2.CastMessage.STRING
        msg.namespace = namespace
        msg.payload_utf8 = _json_to_payload(data)

        # prepend message with Big-Endian 4 byte payload size
        be_size = pack(">I", msg.ByteSize())

        # Log all messages except heartbeat
        if msg.namespace != NS_HEARTBEAT:
            self.logger.debug("Sending: %s", _message_to_string(msg, data))

        if not force and self.stop.is_set():
            raise PyChromecastStopped("Socket client's thread is stopped.")
        if not self.connecting and not self._force_recon:
            try:
                self.socket.sendall(be_size + msg.SerializeToString())
            except socket.error:
                self._force_recon = True
                self.logger.info('Error writing to socket.')
        else:
            raise NotConnected("Chromecast is connecting...")

        if not no_add_request_id and callback_function:
            self._request_callbacks[request_id] = {
                'event': threading.Event(),
                'response': None,
                'function': callback_function,
            }

    def send_platform_message(self, namespace, message, inc_session_id=False,
                              callback_function_param=False):
        """ Helper method to send a message to the platform. """
        return self.send_message(PLATFORM_DESTINATION_ID, namespace, message,
                                 inc_session_id, callback_function_param)

    def send_app_message(self, namespace, message, inc_session_id=False,
                         callback_function_param=False):
        """ Helper method to send a message to current running app. """
        if namespace not in self.app_namespaces:
            raise UnsupportedNamespace(
                ("Namespace {} is not supported by current app. "
                 "Supported are {}").format(namespace,
                                            ", ".join(self.app_namespaces)))

        return self.send_message(self.destination_id, namespace, message,
                                 inc_session_id, callback_function_param)

    def register_connection_listener(self, listener):
        """ Register a connection listener for when the socket connection
            changes. Listeners will be called with
            listener.new_connection_status(status) """
        self._connection_listeners.append(listener)

    def _ensure_channel_connected(self, destination_id):
        """ Ensure we opened a channel to destination_id. """
        if destination_id not in self._open_channels:
            self._open_channels.append(destination_id)

            self.send_message(
                destination_id, NS_CONNECTION,
                {MESSAGE_TYPE: TYPE_CONNECT,
                 'origin': {},
                 'userAgent': 'PyChromecast',
                 'senderInfo': {
                     'sdkType': 2,
                     'version': '15.605.1.3',
                     'browserVersion': "44.0.2403.30",
                     'platform': 4,
                     'systemVersion': 'Macintosh; Intel Mac OS X10_10_3',
                     'connectionType': 1}},
                no_add_request_id=True)

    def _disconnect_channel(self, destination_id):
        """ Disconnect a channel with destination_id. """
        if destination_id in self._open_channels:
            self.send_message(
                destination_id, NS_CONNECTION,
                {MESSAGE_TYPE: TYPE_CLOSE, 'origin': {}},
                no_add_request_id=True, force=True)

            self._open_channels.remove(destination_id)

            self.handle_channel_disconnected()

    def handle_channel_disconnected(self):
        """ Handles a channel being disconnected. """
        for namespace in self.app_namespaces:
            if namespace in self._handlers:
                self._handlers[namespace].channel_disconnected()

        self.app_namespaces = []
        self.destination_id = None
        self.session_id = None


class ConnectionController(BaseController):
    """ Controller to respond to connection messages. """

    def __init__(self):
        super(ConnectionController, self).__init__(NS_CONNECTION)

    def receive_message(self, message, data):
        """ Called when a connection message is received. """
        if self._socket_client.is_stopped:
            return True

        if data[MESSAGE_TYPE] == TYPE_CLOSE:
            self._socket_client.handle_channel_disconnected()

            return True

        else:
            return False


class HeartbeatController(BaseController):
    """ Controller to respond to heartbeat messages. """

    def __init__(self):
        super(HeartbeatController, self).__init__(
            NS_HEARTBEAT, target_platform=True)
        self.last_ping = 0
        self.last_pong = time.time()

    def receive_message(self, message, data):
        """ Called when a heartbeat message is received. """
        if self._socket_client.is_stopped:
            return True

        if data[MESSAGE_TYPE] == TYPE_PING:
            try:
                self._socket_client.send_message(
                    PLATFORM_DESTINATION_ID, self.namespace,
                    {MESSAGE_TYPE: TYPE_PONG}, no_add_request_id=True)
            except PyChromecastStopped:
                self._socket_client.logger.debug(
                    "Heartbeat error when sending response, "
                    "Chromecast connection has stopped")

            return True

        elif data[MESSAGE_TYPE] == TYPE_PONG:
            self.reset()
            return True

        else:
            return False

    def ping(self):
        """ Send a ping message. """
        self.last_ping = time.time()
        try:
            self.send_message({MESSAGE_TYPE: TYPE_PING})
        except NotConnected:
            self._socket_client.logger.error("Chromecast is disconnected. " +
                                             "Cannot ping until reconnected.")

    def reset(self):
        """ Reset expired counter. """
        self.last_pong = time.time()

    def is_expired(self):
        """ Indicates if connection has expired. """
        if time.time() - self.last_ping > HB_PING_TIME:
            self.ping()

        return (time.time() - self.last_pong) > HB_PING_TIME + HB_PONG_TIME


class ReceiverController(BaseController):
    """
    Controller to interact with the Chromecast platform.

    :param cast_type: Type of Chromecast device.
    """

    def __init__(self, cast_type=CAST_TYPE_CHROMECAST, blocking=True):
        super(ReceiverController, self).__init__(
            NS_RECEIVER, target_platform=True)

        self.status = None
        self.launch_failure = None
        self.app_to_launch = None
        self.cast_type = cast_type
        self.blocking = blocking
        self.app_launch_event = threading.Event()
        self.app_launch_event_function = None

        self._status_listeners = []
        self._launch_error_listeners = []

    @property
    def app_id(self):
        """ Convenience method to retrieve current app id. """
        return self.status.app_id if self.status else None

    def receive_message(self, message, data):
        """ Called when a receiver-message has been received. """
        if data[MESSAGE_TYPE] == TYPE_RECEIVER_STATUS:
            self._process_get_status(data)

            return True

        elif data[MESSAGE_TYPE] == TYPE_LAUNCH_ERROR:
            self._process_launch_error(data)

            return True

        else:
            return False

    def register_status_listener(self, listener):
        """ Register a status listener for when a new Chromecast status
            has been received. Listeners will be called with
            listener.new_cast_status(status) """
        self._status_listeners.append(listener)

    def register_launch_error_listener(self, listener):
        """ Register a listener for when a new launch error message
            has been received. Listeners will be called with
            listener.new_launch_error(launch_failure) """
        self._launch_error_listeners.append(listener)

    def update_status(self, callback_function_param=False):
        """ Sends a message to the Chromecast to update the status. """
        self.logger.debug("Receiver:Updating status")
        self.send_message({MESSAGE_TYPE: TYPE_GET_STATUS},
                          callback_function=callback_function_param)

    def launch_app(self, app_id, force_launch=False, callback_function=False):
        """ Launches an app on the Chromecast.

            Will only launch if it is not currently running unless
            force_launch=True. """

        if not force_launch and self.app_id is None:
            self.update_status(lambda response:
                               self._send_launch_message(app_id, force_launch,
                                                         callback_function))
        else:
            self._send_launch_message(app_id, force_launch, callback_function)

    def _send_launch_message(self, app_id, force_launch=False,
                             callback_function=False):
        if force_launch or self.app_id != app_id:
            self.logger.info("Receiver:Launching app %s", app_id)

            self.app_to_launch = app_id
            self.app_launch_event.clear()
            self.app_launch_event_function = callback_function
            self.launch_failure = None

            self.send_message({MESSAGE_TYPE: TYPE_LAUNCH,
                               APP_ID: app_id},
                              callback_function=lambda response:
                              self._block_till_launched(app_id))
        else:
            self.logger.info(
                "Not launching app %s - already running", app_id)
            if callback_function:
                callback_function()

    def _block_till_launched(self, app_id):
        if self.blocking:
            self.app_launch_event.wait()
            if self.launch_failure:
                raise LaunchError(
                    "Failed to launch app: {}, Reason: {}".format(
                        app_id, self.launch_failure.reason))

    def stop_app(self, callback_function_param=False):
        """ Stops the current running app on the Chromecast. """
        self.logger.info("Receiver:Stopping current app '%s'", self.app_id)
        return self.send_message(
            {MESSAGE_TYPE: 'STOP'},
            inc_session_id=True, callback_function=callback_function_param)

    def set_volume(self, volume):
        """ Allows to set volume. Should be value between 0..1.
        Returns the new volume.

        """
        volume = min(max(0, volume), 1)
        self.logger.info("Receiver:setting volume to %.1f", volume)
        self.send_message({MESSAGE_TYPE: 'SET_VOLUME',
                           'volume': {'level': volume}})
        return volume

    def set_volume_muted(self, muted):
        """ Allows to mute volume. """
        self.send_message(
            {MESSAGE_TYPE: 'SET_VOLUME',
             'volume': {'muted': muted}})

    @staticmethod
    def _parse_status(data, cast_type):
        """
        Parses a STATUS message and returns a CastStatus object.

        :type data: dict
        :param cast_type: Type of Chromecast.
        :rtype: CastStatus
        """
        data = data.get('status', {})

        volume_data = data.get('volume', {})

        try:
            app_data = data['applications'][0]
        except KeyError:
            app_data = {}

        is_audio = cast_type in (CAST_TYPE_AUDIO, CAST_TYPE_GROUP)

        status = CastStatus(
            data.get('isActiveInput', None if is_audio else False),
            data.get('isStandBy', None if is_audio else True),
            volume_data.get('level', 1.0),
            volume_data.get('muted', False),
            app_data.get(APP_ID),
            app_data.get('displayName'),
            [item['name'] for item in app_data.get('namespaces', [])],
            app_data.get(SESSION_ID),
            app_data.get('transportId'),
            app_data.get('statusText', '')
            )
        return status

    def _process_get_status(self, data):
        """ Processes a received STATUS message and notifies listeners. """
        status = self._parse_status(data, self.cast_type)
        is_new_app = self.app_id != status.app_id and self.app_to_launch
        self.status = status

        self.logger.debug("Received status: %s", self.status)
        self._report_status()

        if is_new_app and self.app_to_launch == self.app_id:
            self.app_to_launch = None
            self.app_launch_event.set()
            if self.app_launch_event_function:
                self.logger.debug("Start app_launch_event_function...")
                self.app_launch_event_function()
                self.app_launch_event_function = None

    def _report_status(self):
        """ Reports the current status to all listeners. """
        for listener in self._status_listeners:
            try:
                listener.new_cast_status(self.status)
            except Exception:  # pylint: disable=broad-except
                pass

    @staticmethod
    def _parse_launch_error(data):
        """
        Parses a LAUNCH_ERROR message and returns a LaunchFailure object.

        :type data: dict
        :rtype: LaunchFailure
        """
        return LaunchFailure(
            data.get(ERROR_REASON, None),
            data.get(APP_ID),
            data.get(REQUEST_ID),
        )

    def _process_launch_error(self, data):
        """
        Processes a received LAUNCH_ERROR message and notifies listeners.
        """
        launch_failure = self._parse_launch_error(data)
        self.launch_failure = launch_failure

        if self.app_to_launch:
            self.app_to_launch = None
            self.app_launch_event.set()

        self.logger.debug("Launch status: %s", launch_failure)

        for listener in self._launch_error_listeners:
            try:
                listener.new_launch_error(launch_failure)
            except Exception:  # pylint: disable=broad-except
                pass

    def tear_down(self):
        """ Called when controller is destroyed. """
        super(ReceiverController, self).tear_down()

        self.status = None
        self.launch_failure = None
        self.app_to_launch = None
        self.app_launch_event.clear()
        self._report_status()

        self._status_listeners[:] = []
