# -*- coding: UTF-8 -*-

#  Copyright (C) 2019 Parrot Drones SAS
#
#  Redistribution and use in source and binary forms, with or without
#  modification, are permitted provided that the following conditions
#  are met:
#  * Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
#  * Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in
#    the documentation and/or other materials provided with the
#    distribution.
#  * Neither the name of the Parrot Company nor the names
#    of its contributors may be used to endorse or promote products
#    derived from this software without specific prior written
#    permission.
#
#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
#  "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
#  LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
#  FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
#  PARROT COMPANY BE LIABLE FOR ANY DIRECT, INDIRECT,
#  INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
#  BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
#  OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
#  AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
#  OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT
#  OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
#  SUCH DAMAGE.


from __future__ import unicode_literals
from __future__ import absolute_import
from future.builtins import str

import ctypes
import olympe_deps as od
import errno
import json
import numpy as np
import os
import re
import threading
import traceback
from aenum import Enum, auto
from collections import defaultdict, namedtuple
from concurrent.futures import TimeoutError as FutureTimeoutError
from logging import getLogger

from olympe._private import py_object_cast, callback_decorator
from olympe._private.pomp_loop_thread import Future, PompLoopThread


_copyright__ = "Copyright 2018, Parrot"


class PdrawState(Enum):
    Created = auto()
    Closing = auto()
    Closed = auto()
    Opening = auto()
    Opened = auto()
    Playing = auto()
    Paused = auto()
    Error = auto()


VMetaFrameType = Enum(
    'VMetaFrameType',
    {
        re.compile('^VMETA_FRAME_TYPE_').sub('', v): k
        for k, v in od.vmeta_frame_type__enumvalues.items()
    }
)


PDRAW_LOCAL_ADDR = b"0.0.0.0"
PDRAW_LOCAL_STREAM_PORT = 55004
PDRAW_LOCAL_CONTROL_PORT = 55005
PDRAW_REMOTE_STREAM_PORT = 0
PDRAW_REMOTE_CONTROL_PORT = 0
PDRAW_IFACE_ADRR = b""


class H264Header(namedtuple('H264Header', ['sps', 'spslen', 'pps', 'ppslen'])):

    def tofile(self, f):
        start = bytearray([0, 0, 0, 1])
        if self.spslen > 0:
            f.write(start)
            f.write(bytearray(self.sps[:self.spslen]))

        if self.ppslen > 0:
            f.write(start)
            f.write(bytearray(self.pps[:self.ppslen]))


def StreamFactory():
    return {
        'id': None,
        'id_userdata': ctypes.c_void_p(),
        'type': od.PDRAW_VIDEO_MEDIA_FORMAT_UNKNOWN,
        'h264_header': None,
        'video_sink': od.POINTER_T(od.struct_pdraw_video_sink)(),
        'video_sink_lock': threading.RLock(),
        'video_queue': None,
        'video_queue_event': None,
    }


class VideoFrame:

    def __init__(self, logger, buf, media_id, stream, yuv_packed_buffer_pool,
                 session_metadata):
        self.logger = logger
        self._buf = buf
        self._media_id = media_id
        self._stream = stream
        self._yuv_packed_buffer_pool = yuv_packed_buffer_pool
        self._session_metadata = session_metadata
        self._pdraw_video_frame = od.POINTER_T(ctypes.c_ubyte)()
        self._frame_pointer = ctypes.POINTER(ctypes.c_ubyte)()
        self._frame_size = 0
        self._frame_array = None
        self._yuv_packed_buffer = od.POINTER_T(od.struct_vbuf_buffer)()
        self._yuv_packed_video_frame_storage = od.struct_pdraw_video_frame()
        self._yuv_packed_video_frame = od.POINTER_T(
            od.struct_pdraw_video_frame)()
        self._frame_info = None
        self._metadata_pointers = []

    def __bool__(self):
        return bool(self._buf)

    __nonzero__ = __bool__

    def ref(self):
        """
        This function increments the reference counter of the underlying buffer(s)
        """
        try:
            # try to allocate the yuv packed buffer before referencing it
            self._get_pdraw_video_frame()
        finally:
            if self._yuv_packed_buffer:
                od.vbuf_ref(self._yuv_packed_buffer)
            od.vbuf_ref(self._buf)

    def unref(self):
        """
        This function decrements the reference counter of the underlying buffer(s)
        """
        try:
            res = od.vbuf_unref(self._buf)
            if res != 0:
                self.logger.error("vbuf_unref unpacked frame error: {} {} {}".format(
                    self._media_id,
                    os.strerror(-res),
                    ctypes.addressof(self._buf.contents)
                ))
        finally:
            if self._yuv_packed_buffer:
                res = od.vbuf_unref(self._yuv_packed_buffer)
                if res != 0:
                    self.logger.error("vbuf_unref packed frame error: {} {} {}".format(
                        self._media_id,
                        os.strerror(-res),
                        ctypes.addressof(self._buf.contents)
                    ))

    def media_id(self):
        return self._media_id

    def _get_pdraw_video_frame(self):
        if not self._pdraw_video_frame:
            res = od.vbuf_metadata_get(
                self._buf,
                self._stream['video_sink'],
                od.POINTER_T(ctypes.c_uint32)(),
                od.POINTER_T(ctypes.c_uint64)(),
                ctypes.byref(self._pdraw_video_frame))

            if res < 0:
                self.logger.error(
                    'vbuf_metadata_get returned error {}'.format(res))
                self._pdraw_video_frame = od.POINTER_T(ctypes.c_ubyte)()
                return self._pdraw_video_frame
            self._pdraw_video_frame = ctypes.cast(
                self._pdraw_video_frame, od.POINTER_T(od.struct_pdraw_video_frame))

        if self._stream['type'] == od.PDRAW_VIDEO_MEDIA_FORMAT_H264:
            return self._pdraw_video_frame

        if self._yuv_packed_video_frame:
            return self._yuv_packed_video_frame

        if not self._yuv_packed_buffer:
            res = od.vbuf_pool_get(
                self._yuv_packed_buffer_pool,
                0,
                ctypes.byref(self._yuv_packed_buffer)
            )
            if res < 0:
                self.logger.error(
                    'vbuf_pool_get returned error {}'.format(res))
                return self._yuv_packed_video_frame
        self._yuv_packed_video_frame = ctypes.pointer(
            self._yuv_packed_video_frame_storage)
        res = od.pdraw_pack_yuv_frame(
            self._pdraw_video_frame,
            self._yuv_packed_video_frame,
            self._yuv_packed_buffer)
        if res < 0:
            self._yuv_packed_video_frame = od.POINTER_T(
                od.struct_pdraw_video_frame)()
            self.logger.error(
                'pdraw_pack_yuv_frame returned error {}'.format(res))
        return self._yuv_packed_video_frame

    def as_ctypes_pointer(self):
        """
        This function return a 2-tuple (frame_pointer, frame_size) where
        frame_pointer is a ctypes pointer and frame_size the frame size in bytes.

        See: https://docs.python.org/3/library/ctypes.html
        """
        if self._frame_pointer:
            return self._frame_pointer, self._frame_size

        # H264 stream
        if self._stream['type'] == od.PDRAW_VIDEO_MEDIA_FORMAT_H264:
            # get the size in bytes of the raw data
            self._frame_size = od.vbuf_get_size(self._buf)
            self.logger.debug("Frame of {} bytes received".format(self._frame_size))

            # retrieve the raw data from the buffer
            od.vbuf_get_cdata.restype = ctypes.POINTER(ctypes.c_ubyte)
            self._frame_pointer = od.vbuf_get_cdata(self._buf)
            if not self._frame_pointer:
                self.logger.warning('vbuf_get_cdata returned null pointer')
                return self._frame_pointer, 0
            return self._frame_pointer, self._frame_size

        # YUV I420 or NV12 stream
        elif self._stream['type'] == od.PDRAW_VIDEO_MEDIA_FORMAT_YUV:
            frame = self._get_pdraw_video_frame()
            if not frame:
                return self._frame_pointer, self._frame_size
            frame = frame.contents
            self._frame_pointer = ctypes.cast(
                frame._1.yuv.plane[0],
                ctypes.POINTER(ctypes.c_ubyte)
            )
            # assume I420 or NV12 3/2 ratio
            height = frame._1.yuv.height
            width = frame._1.yuv.width
            self._frame_size = int(3 * height * width / 2)
        return self._frame_pointer, self._frame_size

    def as_ndarray(self):
        """
        This function returns an non-owning numpy 1D (h264) or 2D (YUV) array on this video frame
        """
        if self._frame_array is not None:
            return self._frame_array
        frame_pointer, frame_size = self.as_ctypes_pointer()
        if not frame_pointer:
            return self._frame_array
        if self._stream['type'] == od.PDRAW_VIDEO_MEDIA_FORMAT_H264:
            shape = (frame_size,)
        elif self._stream['type'] == od.PDRAW_VIDEO_MEDIA_FORMAT_YUV:
            frame = self._get_pdraw_video_frame()
            if not frame:
                return self._frame_array
            frame = frame.contents
            height = frame._1.yuv.height
            width = frame._1.yuv.width
            # assume I420 or NV12 3/2 ratio
            shape = (int(3 * height / 2), width)
        self._frame_array = np.ctypeslib.as_array(
            frame_pointer, shape=shape)
        return self._frame_array

    def info(self):
        """
        Returns a dictionary of video frame info
        """
        if self._frame_info is not None:
            return self._frame_info
        frame = self._get_pdraw_video_frame()
        if not frame:
            return self._frame_info
        # convert the binary metadata into json
        self._frame_info = {}
        jsonbuf = ctypes.create_string_buffer(4096)
        res = od.pdraw_video_frame_to_json_str(
            frame, jsonbuf, ctypes.sizeof(jsonbuf))
        if res < 0:
            self.logger.error(
                'pdraw_frame_metadata_to_json returned error {}'.format(res))
        else:
            self._frame_info = json.loads(str(jsonbuf.value, encoding="utf-8"))
        return self._frame_info

    def vmeta(self):
        """
        Returns a 2-tuple (VMetaFrameType, dictionary of video frame metadata)
        """
        vmeta = {}
        vmeta_type = VMetaFrameType.NONE
        frame = self._get_pdraw_video_frame()
        if not frame:
            return vmeta_type, vmeta
        vmeta = self.info().get('metadata')
        vmeta_type = VMetaFrameType(frame.contents.metadata.type)
        return vmeta_type, vmeta

    def vbuf_userdata_ctypes_pointers(self):
        """
        Unstable/experimental API
        This returns some additional and optional SEI metadata
        """
        userata_pointer = od.vbuf_get_cuserdata(self._buf)
        userdata_size = od.vbuf_get_userdata_size(self._buf)
        userata_pointer = ctypes.cast(
            userata_pointer, ctypes.POINTER(ctypes.c_ubyte * userdata_size))
        return userata_pointer, userdata_size

    def session_metadata(self):
        """
        Returns video stream session metadata
        """
        return self._session_metadata


class Pdraw(object):

    def __init__(self,
                 name=None,
                 device_name=None,
                 buffer_queue_size=8,
                 legacy=False,
                 pdraw_thread_loop=None,
                 ):
        """
        :param name: (optional) pdraw client name (used by Olympe logs)
        :type name: str
        :param device_name: (optional) the drone device name (used by Olympe logs)
        :type device_name: str
        :param buffer_queue_size: (optional) video buffer queue size (defaults to 8)
        :type buffer_queue_size: int
        :param legacy: Defaults to False, set this parameter to True for legacy
            drones (Bebop, Disco, ...) streaming support
        :type legacy: bool
        """

        self.name = name
        self.device_name = device_name
        if self.name is not None:
            self.logger = getLogger("olympe.{}.pdraw".format(self.name))
        elif self.device_name is not None:
            self.logger = getLogger("olympe.pdraw.{}".format(self.device_name))
        else:
            self.logger = getLogger("olympe.pdraw")

        if pdraw_thread_loop is None:
            self.pdraw_thread_loop = PompLoopThread(self.logger)
            self.pdraw_thread_loop.start()
        else:
            self.pdraw_thread_loop = pdraw_thread_loop

        self.callbacks_thread_loop = PompLoopThread(self.logger)
        self.callbacks_thread_loop.start()
        self.buffer_queue_size = buffer_queue_size
        self.pomp_loop = self.pdraw_thread_loop.pomp_loop
        self._legacy = legacy

        self._open_resp_future = Future(self.pdraw_thread_loop)
        self._close_resp_future = Future(self.pdraw_thread_loop)
        self._close_resp_future.add_done_callback(self._on_close_resp_done)
        self._play_resp_future = Future(self.pdraw_thread_loop)
        self._pause_resp_future = Future(self.pdraw_thread_loop)
        self._state = PdrawState.Created
        self._state_lock = threading.Lock()
        self._state_wait_events = {k: list() for k in PdrawState}

        self.pdraw = od.POINTER_T(od.struct_pdraw)()
        self.streams = defaultdict(StreamFactory)
        self.session_metadata = {}

        self.outfiles = {
            od.PDRAW_VIDEO_MEDIA_FORMAT_H264:
            {
                'data': None,
                'meta': None,
                'info': None,
            },
            od.PDRAW_VIDEO_MEDIA_FORMAT_YUV:
            {
                'data': None,
                'meta': None,
                'info': None,
            },
        }

        self.frame_callbacks = {
            od.PDRAW_VIDEO_MEDIA_FORMAT_H264: None,
            od.PDRAW_VIDEO_MEDIA_FORMAT_YUV: None,
        }
        self.start_callback = None
        self.end_callback = None
        self.flush_callbacks = {
            od.PDRAW_VIDEO_MEDIA_FORMAT_H264: None,
            od.PDRAW_VIDEO_MEDIA_FORMAT_YUV: None,
        }

        self.url = None
        self.server_addr = None
        self.resource_name = "live"
        self.media_name = None

        self.local_stream_port = PDRAW_LOCAL_STREAM_PORT
        self.local_control_port = PDRAW_LOCAL_CONTROL_PORT

        self.cbs = od.struct_pdraw_cbs.bind({
            "open_resp": self._open_resp,
            "close_resp": self._close_resp,
            "ready_to_play": self._ready_to_play,
            "play_resp": self._play_resp,
            "pause_resp": self._pause_resp,
            "seek_resp": self._seek_resp,
            "socket_created": self._socket_created,
            "select_demuxer_media": self._select_demuxer_media,
            "media_added": self._media_added,
            "media_removed": self._media_removed,
            "end_of_range": self._end_of_range,
        })

        self.video_sink_cb = od.struct_pdraw_video_sink_cbs.bind({
            "flush": self._video_sink_flush
        })

        self.vbuf_cbs = od.struct_vbuf_cbs()
        res = od.vbuf_generic_get_cbs(ctypes.pointer(self.vbuf_cbs))
        if res != 0:
            msg = "Error while creating vbuf generic callbacks {}".format(res)
            self.logger.error(msg)
            raise RuntimeError("ERROR: {}".format(msg))

        self.yuv_packed_buffer_pool = od.POINTER_T(od.struct_vbuf_pool)()
        res = od.vbuf_pool_new(
            self.buffer_queue_size,
            0,
            0,
            self.vbuf_cbs,
            ctypes.byref(self.yuv_packed_buffer_pool)
        )
        if res != 0:
            msg = "Error while creating yuv packged buffer pool {}".format(res)
            self.logger.error(msg)
            raise RuntimeError("ERROR: {}".format(msg))

        self.pdraw_thread_loop.register_cleanup(self.dispose)

    @property
    def state(self):
        """
        Return the current Pdraw state

        :rtype: PdrawState
        """
        return self._state

    @state.setter
    def state(self, value):
        with self._state_lock:
            self._state = value
            for event in self._state_wait_events[self._state]:
                event.set()
            self._state_wait_events[self._state] = []

    def wait(self, state, timeout=None):
        """
        Wait for the provided Pdraw state

        This function returns True when the requested state is reached or False
        if the timeout duration is reached.

        If the requested state is already reached, this function returns True
        immediately.

        This function may block indefinitely when called without a timeout
        value.

        :type state: PdrawState
        :param timeout: the timeout duration in seconds or None (the default)
        :type timeout: float
        :rtype: bool
        """
        with self._state_lock:
            if self._state == state:
                return True
            event = threading.Event()
            self._state_wait_events[state].append(event)
        return event.wait(timeout=timeout)

    @callback_decorator()
    def dispose(self):
        # cleanup some FDs from the callbacks thread loop that might have been lost
        for stream in self.streams.values():
            if stream['video_queue_event'] is not None:
                self.logger.warning("cleanup leftover pdraw callbacks eventfds")
                self.callbacks_thread_loop.remove_event_from_loop(
                    stream['video_queue_event'])
                stream['video_queue_event'] = None
        if self.callbacks_thread_loop.stop():
            self.logger.info("pdraw callbacks thread loop stopped")
        return self.pdraw_thread_loop.run_async(
            self._dispose_impl)

    @callback_decorator()
    def _dispose_impl(self):
        f = self.close().then(
            lambda _: self._destroy(), deferred=True)
        return f

    @callback_decorator()
    def _destroy(self):
        if self.pdraw:
            self.logger.info("destroying pdraw...")
            res = od.pdraw_destroy(self.pdraw)
            if res != 0:
                self.logger.error("cannot destroy pdraw {}".format(res))
            else:
                self.logger.info("pdraw destroyed")
        if self.yuv_packed_buffer_pool:
            self.logger.info("destroying yuv buffer pool...")
            res = od.vbuf_pool_destroy(self.yuv_packed_buffer_pool)
            if res != 0:
                self.logger.error(
                    "cannot destroy yuv packed buffer pool: {}".format(res))
            else:
                self.logger.info("yuv buffer pool destroyed")
        self.yuv_packed_buffer_pool = od.POINTER_T(od.struct_vbuf_pool)()
        self.pdraw = od.POINTER_T(od.struct_pdraw)()
        if self.pdraw_thread_loop.stop():
            self.logger.info("pdraw thread loop stopped")
        return True

    def _open_single_stream(self):
        """
        Opening pdraw single stream (legacy API)
        """
        res = od.pdraw_open_single_stream(
            self.pdraw,
            PDRAW_LOCAL_ADDR,
            self.local_stream_port,
            self.local_control_port,
            self.server_addr,
            PDRAW_REMOTE_STREAM_PORT,
            PDRAW_REMOTE_CONTROL_PORT,
            PDRAW_IFACE_ADRR
        )

        if res != 0:
            self.logger.error(
                "Error while opening pdraw single stream: {}".format(res))
            return False
        else:
            self.logger.info("Opening pdraw single stream OK")
        return True

    def _open_url(self):
        """
        Opening rtsp streaming url
        """
        if self.resource_name.startswith("replay/"):
            if self.media_name is None:
                self.logger.error(
                    "Error media_name should be provided in video stream replay mode")
                return False
        res = od.pdraw_open_url(self.pdraw, self.url)

        if res != 0:
            self.logger.error(
                "Error while opening pdraw url: {} ({})".format(self.url, res))
            return False
        else:
            self.logger.info("Opening pdraw url OK: {}".format(self.url))
        return True

    @callback_decorator()
    def _open_stream(self):
        """
        Opening pdraw stream using the appropriate method (legacy or rtsp)
        according to the device type
        """
        self._open_resp_future = Future(self.pdraw_thread_loop)
        if self.state not in (PdrawState.Error, PdrawState.Closed, PdrawState.Created):
            self.logger.warning("Cannot open stream from {}".format(self.state))
            self._open_resp_future.set_result(False)
            return self._open_resp_future

        self.state = PdrawState.Opening
        if not self._pdraw_new():
            self._open_resp_future.set_result(False)
            return self._open_resp_future

        if not self._legacy:
            ret = self._open_url()
        else:
            ret = self._open_single_stream()

        if not ret:
            self._open_resp_future.set_result(False)

        return self._open_resp_future

    def close(self):
        """
        Close a playing or paused video stream session
        """
        if self.state in (PdrawState.Opened, PdrawState.Paused, PdrawState.Playing, PdrawState.Error):
            self.logger.debug("pdraw closing from the {} state".format(self.state))
            self._close_resp_future = Future(self.pdraw_thread_loop)
            self._close_resp_future.add_done_callback(self._on_close_resp_done)
            f = self._close_resp_future
            self.state = PdrawState.Closing
            self.pdraw_thread_loop.run_async(self._close_stream)
        elif self.state is not PdrawState.Closing:
            f = Future(self.pdraw_thread_loop)
            f.set_result(False)
        else:
            f = self._close_resp_future
        return f

    @callback_decorator()
    def _close_stream(self):
        """
        Close pdraw stream
        """
        if self.state is PdrawState.Closed:
            self.logger.info("pdraw is already closed".format(self.state))
            self._close_resp_future.set_result(True)
            return self._close_resp_future

        if not self.pdraw:
            self.logger.error("Error Pdraw interface seems to be destroyed")
            self.state = PdrawState.Error
            self._close_resp_future.set_result(False)
            return self._close_resp_future

        if not self._close_stream_impl():
            self.state = PdrawState.Error
            self._close_resp_future.set_result(False)

        return self._close_resp_future

    def _close_stream_impl(self):
        res = od.pdraw_close(self.pdraw)

        if res != 0:
            self.logger.error(
                "Error while closing pdraw stream: {}".format(res))
            self.state = PdrawState.Error
            return False
        else:
            self.logger.info("Closing pdraw stream OK")

        return True

    @callback_decorator()
    def _on_close_resp_done(self, close_resp_future):
        if close_resp_future.cancelled():
            # FIXME: workaround pdraw closing timeout
            # This random issue is quiet hard to reproduce
            self.logger.error("Closing Pdraw timedout")
            if self.pdraw:
                self.pdraw_thread_loop.run_later(od.pdraw_destroy, self.pdraw)
            self.pdraw = od.POINTER_T(od.struct_pdraw)()
            self.state = PdrawState.Error
            self.logger.error("Pdraw has been closed")

    def _open_resp(self, pdraw, status, userdata):
        self.logger.debug("_open_resp called")
        self.local_stream_port = od.pdraw_get_single_stream_local_stream_port(self.pdraw)

        self.local_control_port = od.pdraw_get_single_stream_local_control_port(self.pdraw)

        if status != 0:
            self.state = PdrawState.Error
        else:
            self.state = PdrawState.Opened

        self._open_resp_future.set_result(status == 0)

    def _close_resp(self, pdraw, status, userdata):
        self._close_output_files()
        if status != 0:
            self.logger.error("_close_resp called {}".format(status))
            self._close_resp_future.set_result(False)
            self.state = PdrawState.Error
        else:
            self.logger.info("_close_resp called {}".format(status))
            self.state = PdrawState.Closed
            self._close_resp_future.set_result(True)

        if self.pdraw:
            res = od.pdraw_destroy(self.pdraw)
            if res != 0:
                self.logger.error("Cannot destroy pdraw object")
        self.pdraw = od.POINTER_T(od.struct_pdraw)()
        self._close_resp_future.set_result(True)

    def _pdraw_new(self):
        res = od.pdraw_new(
            self.pomp_loop,
            self.cbs,
            ctypes.cast(ctypes.pointer(ctypes.py_object(self)), ctypes.c_void_p),
            ctypes.byref(self.pdraw)
        )
        if res != 0:
            msg = "Error while creating pdraw interface: {}".format(res)
            self.logger.error(msg)
            self.pdraw = od.POINTER_T(od.struct_pdraw)()
            return False
        else:
            self.logger.info("Pdraw interface has been created")
            return True

    def _ready_to_play(self, pdraw, ready, userdata):
        self.logger.info("_ready_to_play({}) called".format(ready))
        self._is_ready_to_play = bool(ready)
        if self._is_ready_to_play:
            self._play_resp_future = Future(self.pdraw_thread_loop)
            self._play_impl()
            if self.start_callback is not None:
                self.callbacks_thread_loop.run_async(self.start_callback)
        else:
            if self.end_callback is not None:
                self.callbacks_thread_loop.run_async(self.end_callback)

    def _play_resp(self, pdraw, status, timestamp, speed, userdata):
        if status == 0:
            self.logger.debug("_play_resp called {}".format(status))
            self.state = PdrawState.Playing
            self._play_resp_future.set_result(True)
        else:
            self.logger.error("_play_resp called {}".format(status))
            self.state = PdrawState.Error
            self._play_resp_future.set_result(False)

    def _pause_resp(self, pdraw, status, timestamp, userdata):
        if status == 0:
            self.logger.debug("_pause_resp called {}".format(status))
            self.state = PdrawState.Paused
            self._pause_resp_future.set_result(True)
        else:
            self.logger.error("_pause_resp called {}".format(status))
            self.state = PdrawState.Error
            self._pause_resp_future.set_result(False)

    def _seek_resp(self, pdraw, status, timestamp, userdata):
        if status == 0:
            self.logger.debug("_seek_resp called {}".format(status))
        else:
            self.logger.error("_seek_resp called {}".format(status))
            self.state = PdrawState.Error

    def _socket_created(self, pdraw, fd, userdata):
        self.logger.debug("_socket_created called")

    def _select_demuxer_media(self, pdraw, media, count, userdata):
        # by default select the default media (media_id=0)
        selected_media_id = 0
        selected_media_idx = 0
        for idx in range(count):
            self.logger.info(
                "_select_demuxer_media: "
                "idx={} media_id={} name={} default={}".format(
                    idx, media[idx].media_id,
                    od.string_cast(media[idx].name),
                    str(bool(media[idx].is_default)))
            )
            if (self.media_name is not None and
                    self.media_name == od.string_cast(media[idx].name)):
                selected_media_id = media[idx].media_id
                selected_media_idx = idx
        if (
            self.media_name is not None and
            od.string_cast(media[selected_media_idx].name) != self.media_name
        ):
            self.logger.warning(
                "media_name {} is unavailable. "
                "Selecting the default media instead".format(self.media_name)
            )
        return selected_media_id

    def _media_added(self, pdraw, media_info, userdata):
        id_ = int(media_info.contents.id)
        self.logger.info("_media_added id : {}".format(id_))

        # store the information if supported media type, otherwise exit
        if (media_info.contents._2.video.format !=
                od.PDRAW_VIDEO_MEDIA_FORMAT_YUV and
                media_info.contents._2.video.format !=
                od.PDRAW_VIDEO_MEDIA_FORMAT_H264):
            self.logger.warning(
                'Ignoring media id {} (type {})'.format(
                    id_, media_info.contents._2.video.format))
            return
        self.streams[id_]['type'] = int(media_info.contents._2.video.format)
        if (media_info.contents._2.video.format ==
                od.PDRAW_VIDEO_MEDIA_FORMAT_H264):
                header = media_info.contents._2.video._2.h264
                header = H264Header(
                    bytearray(header.sps),
                    int(header.spslen),
                    bytearray(header.pps),
                    int(header.ppslen),
                )
                self.streams[id_]['h264_header'] = header

        # start a video sink attached to the new media
        video_sink_params = od.struct_pdraw_video_sink_params(
            self.buffer_queue_size,  # buffer queue size
            1,  # drop buffers when the queue is full
        )
        self.streams[id_]['id_userdata'] = ctypes.cast(
            ctypes.pointer(ctypes.py_object(id_)), ctypes.c_void_p)
        self.streams[id_]['id'] = id_

        res = od.pdraw_start_video_sink(
            pdraw,
            id_,
            video_sink_params,
            self.video_sink_cb,
            self.streams[id_]['id_userdata'],
            ctypes.byref(self.streams[id_]['video_sink'])
        )
        if res != 0 or not self.streams[id_]['video_sink']:
            self.logger.error("Unable to start video sink")
            return

        # Retrieve the queue belonging to the sink
        queue = od.pdraw_get_video_sink_queue(
            pdraw,
            self.streams[id_]['video_sink'],
        )
        self.streams[id_]['video_queue'] = queue

        # Retrieve event object and related file descriptor
        self.streams[id_]['video_queue_event'] = \
            od.vbuf_queue_get_evt(self.streams[id_]['video_queue'])

        # add the file description to our pomp loop
        self.callbacks_thread_loop.add_event_to_loop(
            self.streams[id_]['video_queue_event'],
            lambda *args: self._video_sink_queue_event(*args),
            id_
        )

    def _media_removed(self, pdraw, media_info, userdata):
        id_ = media_info.contents.id
        if id_ not in self.streams:
            self.logger.error(
                'Received removed event from unknown ID {}'.format(id_))
            return

        self.logger.info("_media_removed called id : {}".format(id_))

        # FIXME: Workaround media_removed called with destroyed media
        if not self.pdraw:
            self.logger.error(
                "_media_removed called with a destroyed pdraw id : {}".format(
                    id_)
            )
            return
        with self.streams[id_]['video_sink_lock']:
            if self.streams[id_]['video_queue_event']:
                self.callbacks_thread_loop.remove_event_from_loop(
                    self.streams[id_]['video_queue_event'])
                self.streams[id_]['video_queue_event'] = None

            if not self.streams[id_]['video_sink']:
                self.logger.error(
                    'pdraw_video_sink for media_id {} has already been stopped'.format(id_))
                return
            res = od.pdraw_stop_video_sink(pdraw, self.streams[id_]['video_sink'])
            if res < 0:
                self.logger.error('pdraw_stop_video_sink() returned %s' % res)
            self.streams[id_]['video_queue'] = None
            self.streams[id_]['video_sink'] = od.POINTER_T(od.struct_pdraw_video_sink)()

    def _end_of_range(self, pdraw, timestamp, userdata):
        self.logger.info("_end_of_range")
        self.close()

    def _video_sink_flush(self, pdraw, videosink, userdata):
        id_ = py_object_cast(userdata)
        if id_ not in self.streams:
            self.logger.error(
                'Received flush event from unknown ID {}'.format(id_))
            return -errno.ENOENT

        # FIXME: Workaround video_sink_flush called with destroyed media
        if not self.pdraw:
            self.logger.error(
                "_video_sink_flush called with a destroyed pdraw id : {}".format(
                    id_)
            )
            return -errno.EINVAL

        # FIXME: Workaround video_sink_flush called with destroyed video queue
        if not self.streams[id_]['video_queue']:
            self.logger.error(
                "_video_sink_flush called with a destroyed queue id : {}".format(
                    id_)
            )
            return -errno.EINVAL

        with self.streams[id_]['video_sink_lock']:
            self.logger.debug("flush_callback {}".format(id_))

            flush_callback = self.flush_callbacks[self.streams[id_]['type']]
            if flush_callback is not None:
                flushed = self.callbacks_thread_loop.run_async(flush_callback)
                try:
                    if not flushed.result_or_cancel(timeout=5.):
                        self.logger.error(
                            'video sink flush id {} error'.format(id_))
                except FutureTimeoutError:
                    self.logger.error(
                        'video sink flush id {} timeout'.format(id_))
                # NOTE: If the user failed to flush its buffer at this point,
                # bad things WILL happen we're acknowledging the buffer flush
                # in all cases...
            res = od.vbuf_queue_flush(self.streams[id_]['video_queue'])
            if res < 0:
                self.logger.error('vbuf_queue_flush() returned %s' % res)
            else:
                self.logger.info('vbuf_queue_flush() returned %s' % res)

            res = od.pdraw_video_sink_queue_flushed(pdraw, videosink)
            if res < 0:
                self.logger.error(
                    'pdraw_video_sink_queue_flushed() returned %s' % res)
            else:
                self.logger.debug(
                    'pdraw_video_sink_queue_flushed() returned %s' % res)
            return 0

    @callback_decorator()
    def _video_sink_queue_event(self, pomp_evt, userdata):
        id_ = py_object_cast(userdata)
        self.logger.debug('media id = {}'.format(id_))

        if id_ not in self.streams:
            self.logger.error(
                'Received queue event from unknown ID {}'.format(id_))
            return

        # acknowledge event
        res = od.pomp_evt_clear(self.streams[id_]['video_queue_event'])
        if res != 0:
            self.logger.error(
                "Unable to clear frame received event ({})".format(res))

        if not self._is_ready_to_play:
            self.logger.debug("The stream is no longer ready: drop one frame")
            return

        # process all available buffers in the queue
        with self.streams[id_]['video_sink_lock']:
            while self._process_stream(id_):
                pass

    def _pop_stream_buffer(self, id_):
        buf = od.POINTER_T(od.struct_vbuf_buffer)()
        ret = od.vbuf_queue_pop(
            self.streams[id_]['video_queue'], 0, ctypes.byref(buf)
        )
        if ret < 0:
            if ret != -errno.EAGAIN:
                self.logger.error('vbuf_queue_pop returned error %d' % ret)
            buf = od.POINTER_T(od.struct_vbuf_buffer)()
        elif not buf:
            self.logger.error('vbuf_queue_pop returned NULL')
        return buf

    def _process_stream(self, id_):
        self.logger.debug('media id = {}'.format(id_))
        if od.vbuf_queue_get_count(self.streams[id_]['video_queue']) == 0:
            return False
        buf = self._pop_stream_buffer(id_)
        if not buf:
            return False
        video_frame = VideoFrame(
            self.logger,
            buf,
            id_,
            self.streams[id_],
            self.yuv_packed_buffer_pool,
            self.get_session_metadata()
        )
        try:
            self._process_stream_buffer(id_, video_frame)
            return True
        except Exception:
            self.logger.error('_process_stream_buffer exception:\n{}'.format(
                traceback.format_exc()))
            return False
        finally:
            # Once we're done with this frame, dispose the associated frame buffer
            video_frame.unref()

    def _process_stream_buffer(self, id_, video_frame):
        stream = self.streams[id_]
        mediatype = stream['type']

        # write and/or send data over the requested channels
        # handle output files
        files = self.outfiles[mediatype]

        f = files['meta']
        if f and not f.closed:
            vmeta_type, vmeta = video_frame.vmeta()
            files['meta'].write(json.dumps((str(vmeta_type), vmeta)) + '\n')

        f = files['info']
        if f and not f.closed:
            info = video_frame.info()
            files['info'].write(json.dumps(info) + '\n')

        f = files['data']
        if f and not f.closed:
            if mediatype == od.PDRAW_VIDEO_MEDIA_FORMAT_H264:
                if f.tell() == 0:
                    # h264 files need a header to be readable
                    stream['h264_header'].tofile(f)
            frame_array = video_frame.as_ndarray()
            if frame_array is not None:
                f.write(ctypes.string_at(
                    frame_array.ctypes.data_as(ctypes.POINTER(ctypes.c_ubyte)),
                    frame_array.size,
                ))

        # call callbacks when existing
        cb = self.frame_callbacks[mediatype]
        if cb is not None:
            cb(video_frame)

    def set_output_files(self,
                         h264_data_file,
                         h264_meta_file,
                         h264_info_file,
                         raw_data_file,
                         raw_meta_file,
                         raw_info_file):
        """
        Records the video stream session to the disk

        - xxx_meta_file: video stream metadata output files
        - xxx_data_file: video stream frames output files
        - xxx_info_file: video stream frames info files
        - h264_***_file: files associated to the H264 encoded video stream
        - raw_***_file: files associated to the decoded video stream

        This function MUST NOT be called when a video streaming session is
        active.
        Setting a file parameter to `None` disables the recording for the
        related stream part.
        """
        if self.state is PdrawState.Playing:
            raise RuntimeError(
                'Cannot set video streaming files while streaming is on.')

        for mediatype, datatype, filepath, attrib in (
                (od.PDRAW_VIDEO_MEDIA_FORMAT_H264, 'data', h264_data_file, 'wb'),
                (od.PDRAW_VIDEO_MEDIA_FORMAT_H264, 'meta', h264_meta_file, 'w'),
                (od.PDRAW_VIDEO_MEDIA_FORMAT_H264, 'info', h264_info_file, 'w'),
                (od.PDRAW_VIDEO_MEDIA_FORMAT_YUV,  'data', raw_data_file,  'wb'),
                (od.PDRAW_VIDEO_MEDIA_FORMAT_YUV,  'meta', raw_meta_file,  'w'),
                (od.PDRAW_VIDEO_MEDIA_FORMAT_YUV,  'info', raw_info_file,  'w')):
            if self.outfiles[mediatype][datatype]:
                self.outfiles[mediatype][datatype].close()
                self.outfiles[mediatype][datatype] = None

            if filepath is None:
                continue

            # open and close file to store its filename and attribute
            self.outfiles[mediatype][datatype] = open(filepath, attrib)
            self.outfiles[mediatype][datatype].close()

    def set_callbacks(self,
                      h264_cb=None,
                      raw_cb=None,
                      start_cb=None,
                      end_cb=None,
                      flush_h264_cb=None,
                      flush_raw_cb=None):
        """
        Set the callback functions that will be called when a new video stream frame is available,
        when the video stream starts/ends or when the video buffer needs to get flushed.

        **Video frame callbacks**

        - `h264_cb` is associated to the H264 encoded video stream
        - `raw_cb` is associated to the decoded video stream

        Each video frame callback function takes an :py:func:`~olympe.VideoFrame` parameter whose
        lifetime ends after the callback execution. If this video frame is passed to another thread,
        its internal reference count need to be incremented first by calling
        :py:func:`~olympe.VideoFrame.ref`. In this case, once the frame is no longer needed, its
        reference count needs to be decremented so that this video frame can be returned to
        memory pool.

        **Video flush callbacks**

        - `flush_h264_cb` is associated to the H264 encoded video stream
        - `flush_raw_cb` is associated to the decoded video stream

        Video flush callback functions are called when a video stream reclaim all its associated
        video buffer. Every frame that has been referenced

        **Start/End callbacks**

        The `start_cb`/`end_cb` callback functions are called when the video stream start/ends.
        They don't accept any parameter.

        The return value of all these callback functions are ignored.
        If a callback is not desired, leave the parameter to its default value or set it to `None`
        explicitly.
        """

        for mediatype, cb in ((od.PDRAW_VIDEO_MEDIA_FORMAT_H264, h264_cb),
                              (od.PDRAW_VIDEO_MEDIA_FORMAT_YUV, raw_cb)):
            self.frame_callbacks[mediatype] = callback_decorator(self.logger)(cb)
        for mediatype, cb in ((od.PDRAW_VIDEO_MEDIA_FORMAT_H264, flush_h264_cb),
                              (od.PDRAW_VIDEO_MEDIA_FORMAT_YUV, flush_raw_cb)):
            self.flush_callbacks[mediatype] = callback_decorator(self.logger)(cb)
        self.start_callback = callback_decorator(self.logger)(start_cb)
        self.end_callback = callback_decorator(self.logger)(end_cb)

    def _open_output_files(self):
        self.logger.debug('opening video output files')
        for mediatype, data in self.outfiles.items():
            for datatype, f in data.items():
                if f and f.closed:
                    self.outfiles[mediatype][datatype] = open(f.name, f.mode)

    def _close_output_files(self):
        self.logger.debug('closing video output files')
        for files in self.outfiles.values():
            for f in files.values():
                if f:
                    f.close()

    def play(self, url=None, media_name="DefaultVideo", server_addr=None, resource_name="live"):
        """
        Play a video

        By default, open and play a live video streaming session available
        from rtsp://192.168.42.1/live where "192.168.42.1" is the default IP
        address of a physical (Anafi) drone. The default is equivalent to
        `Pdraw.play(url="rtsp://192.168.42.1/live")`

        For a the live video streaming from a **simulated drone**, you have to
        specify the default simulated drone IP address (10.202.0.1) instead:
        `Pdraw.play(url="rtsp://10.202.0.1/live")`.

        The `url` parameter can also point to a local file example:
        `Pdraw.play(url="file://~/Videos/100000010001.MP4")`.

        :param url: rtsp or local file video URL
        :type url: str
        :param media_name: name of the media/track (defaults to "DefaultVideo").
            If the provided media name is not available from the requested video
            stream, the default media is selected instead.
        :type media_name: str

        """
        if self.pdraw is None:
            self.logger.error("Error Pdraw interface seems to be destroyed")
            self._play_resp_future.set_result(False)
            return self._pause_resp_future

        if self.state in (PdrawState.Opening, PdrawState.Closing):
            self.logger.warning("Cannot play stream from the {} state".format(
                self.state))
            f = Future(self.pdraw_thread_loop)
            f.set_result(False)
            return f

        self.resource_name = resource_name
        self.media_name = media_name

        if server_addr is None:
            self.server_addr = "192.168.42.1"
        else:
            self.server_addr = server_addr

        if url is None:
            self.url = b"rtsp://%s/%s" % (
                self.server_addr, self.resource_name.encode())
        else:
            if isinstance(url, bytes):
                url = url.decode('utf-8')
            if url.startswith('file://'):
                url = url[7:]
            if url.startswith('~/'):
                url = os.path.expanduser(url)
            url = os.path.expandvars(url)
            url = url.encode('utf-8')
            self.url = url
            if self.is_legacy():
                self.logger.warning("Cannot open streaming url for legacy drones")

        # reset session metadata from any previous session
        self.session_metadata = {}
        self.streams = defaultdict(StreamFactory)

        self._open_output_files()
        if self.state in (PdrawState.Created, PdrawState.Closed):
            f = self.pdraw_thread_loop.run_async(self._open_stream)
        else:
            f = self._play_resp_future = Future(self.pdraw_thread_loop)
            self.pdraw_thread_loop.run_async(self._play_impl)
        return f

    @callback_decorator()
    def _play_impl(self):
        self.logger.debug("play_impl")
        if self.state is PdrawState.Playing:
            self._play_resp_future.set_result(True)
            return self._play_resp_future

        res = od.pdraw_play(self.pdraw)
        if res != 0:
            msg = "Unable to start streaming ({})".format(res)
            self.logger.error(msg)
            self._play_resp_future.set_result(False)

        return self._play_resp_future

    def pause(self):
        """
        Pause the currently playing video
        """
        if self.pdraw is None:
            self.logger.error("Error Pdraw interface seems to be destroyed")
            self._pause_resp_future.set_result(False)
            return self._pause_resp_future

        self._pause_resp_future = Future(self.pdraw_thread_loop)
        if self.state is PdrawState.Playing:
            self.pdraw_thread_loop.run_async(self._pause_impl)
        elif self.state in (PdrawState.Closed, PdrawState.Opened):
            # Pause an opened/closed stream is OK
            self._pause_resp_future.set_result(True)
        else:
            self.logger.warning("Cannot pause stream from the {} state".format(
                self.state))
            self._pause_resp_future.set_result(False)
        return self._pause_resp_future

    @callback_decorator()
    def _pause_impl(self):
        res = od.pdraw_pause(self.pdraw)
        if res != 0:
            self.logger.error("Unable to stop streaming ({})".format(res))
            self._pause_resp_future.set_result(False)
        return self._pause_resp_future

    def get_session_metadata(self):
        """
        Returns a dictionary of video stream session metadata
        """
        if self.pdraw is None:
            self.logger.error("Error Pdraw interface seems to be destroyed")
            return None

        if self.session_metadata:
            return self.session_metadata

        vmeta_session = od.struct_vmeta_session()
        res = od.pdraw_get_peer_session_metadata(
            self.pdraw, ctypes.pointer(vmeta_session))
        if res != 0:
            msg = "Unable to get sessions metata"
            self.logger.error(msg)
            return None
        self.session_metadata = od.struct_vmeta_session.as_dict(
            vmeta_session)
        return self.session_metadata

    def is_legacy(self):
        return self._legacy