"""
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.  See the NOTICE file
distributed with this work for additional information
regarding copyright ownership.  The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License.  You may obtain a copy of the License at

  http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied.  See the License for the
specific language governing permissions and limitations
under the License.
"""
import calendar
import io
import logging
import math
import struct
import uuid
from collections import OrderedDict
from datetime import datetime, timedelta, timezone
from struct import pack, unpack

from aenum import Enum
from gremlin_python.process.traversal import Direction, T, Merge
from gremlin_python.statics import FloatType, BigDecimal, ShortType, IntType, LongType, BigIntType, \
    DictType, SetType, SingleByte, SingleChar
from gremlin_python.structure.graph import Graph, Edge, Property, Vertex, VertexProperty, Path, CompositePDT, \
    PrimitivePDT, Tree, _pdt_decorated_types
from gremlin_python.structure.io.util import HashableDict, SymbolUtil, Marker

log = logging.getLogger(__name__)

# When we fall back to a superclass's serializer, we iterate over this map.
# We want that iteration order to be consistent, so we use an OrderedDict,
# not a dict.
_serializers = OrderedDict()
_deserializers = {}


class DataType(Enum):
    null = 0xfe
    int = 0x01
    long = 0x02
    string = 0x03
    datetime = 0x04
    double = 0x07
    float = 0x08
    list = 0x09
    map = 0x0a
    set = 0x0b
    uuid = 0x0c
    edge = 0x0d
    path = 0x0e
    property = 0x0f
    graph = 0x10                  # not supported - no graph object in python yet
    vertex = 0x11
    vertexproperty = 0x12
    direction = 0x18
    t = 0x20
    merge = 0x2e
    bigdecimal = 0x22
    biginteger = 0x23
    byte = 0x24
    binary = 0x25
    short = 0x26
    boolean = 0x27
    tree = 0x2b
    char = 0x80
    duration = 0x81
    composite_pdt = 0xf0
    primitive_pdt = 0xf1
    marker = 0xfd


NULL_BYTES = [DataType.null.value, 0x01]

# null type code as a plain int, so the per-read null check skips the aenum lookup
_NULL = DataType.null.value


def _make_packer(format_string):
    packer = struct.Struct(format_string)
    pack = packer.pack
    unpack = lambda s: packer.unpack(s)[0]
    return pack, unpack


int64_pack, int64_unpack = _make_packer('>q')
int32_pack, int32_unpack = _make_packer('>i')
int16_pack, int16_unpack = _make_packer('>h')
int8_pack, int8_unpack = _make_packer('>b')
uint64_pack, uint64_unpack = _make_packer('>Q')
uint8_pack, uint8_unpack = _make_packer('>B')
float_pack, float_unpack = _make_packer('>f')
double_pack, double_unpack = _make_packer('>d')


class GraphBinaryTypeType(type):
    def __new__(mcs, name, bases, dct):
        cls = super(GraphBinaryTypeType, mcs).__new__(mcs, name, bases, dct)
        if not name.startswith('_'):
            if cls.python_type:
                _serializers[cls.python_type] = cls
            if cls.graphbinary_type:
                _deserializers[cls.graphbinary_type] = cls
        return cls


class GraphBinaryWriter(object):
    def __init__(self, serializer_map=None):
        self.serializers = _serializers.copy()
        if serializer_map:
            self.serializers.update(serializer_map)

    def write_object(self, object_data):
        return self.to_dict(object_data)

    def to_dict(self, obj, to_extend=None):
        if to_extend is None:
            to_extend = bytearray()

        if obj is None:
            to_extend.extend(NULL_BYTES)
            return

        try:
            t = type(obj)
            return self.serializers[t].dictify(obj, self, to_extend)
        except KeyError:
            for key, serializer in self.serializers.items():
                if isinstance(obj, key):
                    return serializer.dictify(obj, self, to_extend)

        if isinstance(obj, dict):
            return dict((self.to_dict(k, to_extend), self.to_dict(v, to_extend)) for k, v in obj.items())
        elif isinstance(obj, set):
            return set([self.to_dict(o, to_extend) for o in obj])
        elif isinstance(obj, list):
            return [self.to_dict(o, to_extend) for o in obj]
        else:
            return obj


class GraphBinaryReader(object):
    def __init__(self, deserializer_map=None, pdt_registry=None):
        self.deserializers = _deserializers.copy()
        if deserializer_map:
            self.deserializers.update(deserializer_map)
        self.pdt_registry = pdt_registry
        # Mirror of self.deserializers keyed by int type code instead of DataType.
        # Avoids the per-read DataType(bt) call, whose aenum construction negatively affects performance on large results.
        self._deserializer_by_type_code = {dt.value: des.objectify for dt, des in self.deserializers.items()}

    def read_object(self, b):
        if b is None:
            return None
        if isinstance(b, bytearray):
            return self.to_object(io.BytesIO(b))
        return self.to_object(b)

    def to_object(self, buff, data_type=None, nullable=True):
        if data_type is None:
            bt = uint8_unpack(buff.read(1))
            if bt == _NULL:
                if nullable:
                    buff.read(1)
                return None
            try:
                objectify = self._deserializer_by_type_code[bt]
            except KeyError:
                raise ValueError("%r is not a valid DataType" % bt) from None
            result = objectify(buff, self, nullable)
        else:
            result = self.deserializers[data_type].objectify(buff, self, nullable)
        if self.pdt_registry is not None and isinstance(result, PrimitivePDT):
            hydrated = self.pdt_registry.hydrate_primitive(result)
            if not isinstance(hydrated, PrimitivePDT):
                return hydrated
            result = hydrated
        if self.pdt_registry is not None and isinstance(result, CompositePDT):
            hydrated = self.pdt_registry.hydrate(result)
            if not isinstance(hydrated, CompositePDT):
                return hydrated
            result = hydrated
        if isinstance(result, CompositePDT) and result.name in _pdt_decorated_types:
            return self._hydrate_decorated(result)
        return result

    def _hydrate_decorated(self, pdt):
        """Hydrate a CompositePDT using a @provider_defined decorated class."""
        cls = _pdt_decorated_types[pdt.name]
        fields = {}
        for k, v in pdt.fields.items():
            if isinstance(v, CompositePDT) and v.name in _pdt_decorated_types:
                fields[k] = self._hydrate_decorated(v)
            elif self.pdt_registry is not None and isinstance(v, CompositePDT):
                fields[k] = self.pdt_registry.hydrate(v)
            else:
                fields[k] = v
        obj = cls.__new__(cls)
        for k, v in fields.items():
            setattr(obj, k, v)
        return obj


class _GraphBinaryTypeIO(object, metaclass=GraphBinaryTypeType):
    python_type = None
    graphbinary_type = None

    @classmethod
    def prefix_bytes(cls, graphbin_type, as_value=False, nullable=True, to_extend=None, ordered=False):
        if to_extend is None:
            to_extend = bytearray()

        if not as_value:
            to_extend += uint8_pack(graphbin_type.value)

        if nullable:
            if ordered:
                to_extend += int8_pack(2)
            else:
                to_extend += int8_pack(0)

        return to_extend

    @classmethod
    def read_int(cls, buff):
        return int32_unpack(buff.read(4))

    @classmethod
    def is_null(cls, buff, reader, else_opt, nullable=True):
        return None if nullable and buff.read(1)[0] == 0x01 else else_opt(buff, reader)

    def dictify(self, obj, writer, to_extend, as_value=False, nullable=True):
        raise NotImplementedError()

    def objectify(self, d, reader, nullable=True):
        raise NotImplementedError()


class LongIO(_GraphBinaryTypeIO):

    python_type = LongType
    graphbinary_type = DataType.long
    byte_format_pack = int64_pack
    byte_format_unpack = int64_unpack

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        if obj < -9223372036854775808 or obj > 9223372036854775807:
            raise Exception("Value too big, please use bigint Gremlin type")
        else:
            cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
            to_extend.extend(cls.byte_format_pack(obj))
            return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader, lambda b, r: int64_unpack(buff.read(8)), nullable)


class IntIO(LongIO):

    python_type = IntType
    graphbinary_type = DataType.int
    byte_format_pack = int32_pack
    byte_format_unpack = int32_unpack

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader, lambda b, r: cls.read_int(b), nullable)


class ShortIO(LongIO):

    python_type = ShortType
    graphbinary_type = DataType.short
    byte_format_pack = int16_pack
    byte_format_unpack = int16_unpack

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader, lambda b, r: int16_unpack(buff.read(2)), nullable)


class BigIntIO(_GraphBinaryTypeIO):

    python_type = BigIntType
    graphbinary_type = DataType.biginteger

    @classmethod
    def write_bigint(cls, obj, to_extend):
        length = (obj.bit_length() + 7) // 8
        if obj > 0:
            b = obj.to_bytes(length, byteorder='big')
            to_extend.extend(int32_pack(length + 1))
            to_extend.extend(int8_pack(0))
            to_extend.extend(b)
        else:
            # handle negative
            b = obj.to_bytes(length, byteorder='big', signed=True)
            to_extend.extend(int32_pack(length))
            to_extend.extend(b)
        return to_extend

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
        return cls.write_bigint(obj, to_extend)

    @classmethod
    def read_bigint(cls, buff):
        size = cls.read_int(buff)
        return int.from_bytes(buff.read(size), byteorder='big', signed=True)

    @classmethod
    def objectify(cls, buff, reader, nullable=False):
        return cls.is_null(buff, reader, lambda b, r: cls.read_bigint(b), nullable)


def _long_bits_to_double(bits):
    return unpack('d', pack('Q', bits))[0]


NAN = _long_bits_to_double(0x7ff8000000000000)
POSITIVE_INFINITY = _long_bits_to_double(0x7ff0000000000000)
NEGATIVE_INFINITY = _long_bits_to_double(0xFff0000000000000)


class FloatIO(LongIO):

    python_type = FloatType
    graphbinary_type = DataType.float
    graphbinary_base_type = DataType.float
    byte_format_pack = float_pack
    byte_format_unpack = float_unpack

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        if math.isnan(obj):
            cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
            to_extend.extend(cls.byte_format_pack(NAN))
        elif math.isinf(obj) and obj > 0:
            cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
            to_extend.extend(cls.byte_format_pack(POSITIVE_INFINITY))
        elif math.isinf(obj) and obj < 0:
            cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
            to_extend.extend(cls.byte_format_pack(NEGATIVE_INFINITY))
        else:
            cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
            to_extend.extend(cls.byte_format_pack(obj))

        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader, lambda b, r: float_unpack(b.read(4)), nullable)


class DoubleIO(FloatIO):
    """
    Floats basically just fall through to double serialization.
    """

    graphbinary_type = DataType.double
    graphbinary_base_type = DataType.double
    byte_format_pack = double_pack
    byte_format_unpack = double_unpack

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader, lambda b, r: double_unpack(b.read(8)), nullable)


class BigDecimalIO(_GraphBinaryTypeIO):

    python_type = BigDecimal
    graphbinary_type = DataType.bigdecimal

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
        to_extend.extend(int32_pack(obj.scale))
        return BigIntIO.write_bigint(obj.unscaled_value, to_extend)

    @classmethod
    def _read(cls, buff):
        scale = int32_unpack(buff.read(4))
        unscaled_value = BigIntIO.read_bigint(buff)
        return BigDecimal(scale, unscaled_value)

    @classmethod
    def objectify(cls, buff, reader, nullable=False):
        return cls.is_null(buff, reader, lambda b, r: cls._read(b), nullable)


class DateTimeIO(_GraphBinaryTypeIO):

    python_type = datetime
    graphbinary_type = DataType.datetime

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        if obj.tzinfo is None:
            raise AttributeError("Timezone information is required when constructing datetime")
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
        IntIO.dictify(obj.year, writer, to_extend, True, False)
        ByteIO.dictify(obj.month, writer, to_extend, True, False)
        ByteIO.dictify(obj.day, writer, to_extend, True, False)
        # construct time of day in nanoseconds
        h = obj.time().hour
        m = obj.time().minute
        s = obj.time().second
        ms = obj.time().microsecond
        ns = round((h*60*60*1e9) + (m*60*1e9) + (s*1e9) + (ms*1e3))
        LongIO.dictify(ns, writer, to_extend, True, False)
        os = round(obj.utcoffset().total_seconds())
        IntIO.dictify(os, writer, to_extend, True, False)
        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader, cls._read_dt, nullable)

    @classmethod
    def _read_dt(cls, b, r):
        year = r.to_object(b, DataType.int, False)
        month = r.to_object(b, DataType.byte, False)
        day = r.to_object(b, DataType.byte, False)
        ns = r.to_object(b, DataType.long, False)
        offset = r.to_object(b, DataType.int, False)
        tz = timezone(timedelta(seconds=offset))
        return datetime(year, month, day, tzinfo=tz) + timedelta(microseconds=ns/1000)


class CharIO(_GraphBinaryTypeIO):
    python_type = SingleChar
    graphbinary_type = DataType.char

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
        to_extend.extend(obj.encode("utf-8"))
        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader, cls._read_char, nullable)

    @classmethod
    def _read_char(cls, b, r):
        max_bytes = 4
        x = b.read(1)
        while max_bytes > 0:
            max_bytes = max_bytes - 1
            try:
                return x.decode("utf-8")
            except UnicodeDecodeError:
                x += b.read(1)


class StringIO(_GraphBinaryTypeIO):

    python_type = str
    graphbinary_type = DataType.string

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
        str_bytes = obj.encode("utf-8")
        to_extend += int32_pack(len(str_bytes))
        to_extend += str_bytes
        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader, lambda b, r: b.read(cls.read_int(b)).decode("utf-8"), nullable)


class ListIO(_GraphBinaryTypeIO):

    python_type = list
    graphbinary_type = DataType.list

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
        to_extend.extend(int32_pack(len(obj)))
        for item in obj:
            writer.to_dict(item, to_extend)

        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        flag = 0x00
        if nullable:
            flag = buff.read(1)[0]
            if flag == 0x01:
                return None
            else:
                return cls._read_list(buff, reader, flag)
        return cls._read_list(buff, reader, flag)

    @classmethod
    def _read_list(cls, b, r, flag):
        size = cls.read_int(b)
        the_list = []
        if flag == 0x02:
            while size > 0:
                itm = r.read_object(b)
                bulk = int64_unpack(b.read(8))
                for y in range(bulk):
                    the_list.append(itm)
                size = size - 1
        else:
            while size > 0:
                the_list.append(r.read_object(b))
                size = size - 1

        return the_list


class SetDeserializer(ListIO):

    python_type = SetType
    graphbinary_type = DataType.set

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        the_list = ListIO.objectify(buff, reader, nullable)
        try:
            return set(the_list)
        except TypeError:
            log.warning("Coercing Set to list as it contains unhashable elements (e.g. dict, list). "
                        "See TINKERPOP-3232 for more details.")
            return the_list


class MapIO(_GraphBinaryTypeIO):

    python_type = DictType
    graphbinary_type = DataType.map

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend, ordered=isinstance(obj, OrderedDict))

        to_extend.extend(int32_pack(len(obj)))
        for k, v in obj.items():
            writer.to_dict(k, to_extend)
            writer.to_dict(v, to_extend)

        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        flag = 0x00
        if nullable:
            flag = buff.read(1)[0]
            if flag == 0x01:
                return None
            else:
                return cls._read_map(buff, reader, flag)
        return cls._read_map(buff, reader, flag)

    @classmethod
    def _read_map(cls, b, r, flag):
        size = cls.read_int(b)
        the_dict = OrderedDict() if flag == 0x02 else {}
        while size > 0:
            k = HashableDict.of(r.read_object(b))
            v = r.read_object(b)
            the_dict[k] = v
            size = size - 1

        return the_dict


class UuidIO(_GraphBinaryTypeIO):

    python_type = uuid.UUID
    graphbinary_type = DataType.uuid

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
        to_extend.extend(obj.bytes)
        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader, lambda b, r: uuid.UUID(bytes=b.read(16)), nullable)


class EdgeIO(_GraphBinaryTypeIO):

    python_type = Edge
    graphbinary_type = DataType.edge

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)

        writer.to_dict(obj.id, to_extend)
        # serializing labels as list according to GraphBinaryV4
        if hasattr(obj, '_labels'):
            ListIO.dictify(list(obj._labels), writer, to_extend, True, False)
        else:
            ListIO.dictify([obj.label], writer, to_extend, True, False)
        writer.to_dict(obj.inV.id, to_extend)
        if hasattr(obj.inV, '_labels'):
            ListIO.dictify(list(obj.inV._labels), writer, to_extend, True, False)
        else:
            ListIO.dictify([obj.inV.label], writer, to_extend, True, False)
        writer.to_dict(obj.outV.id, to_extend)
        if hasattr(obj.outV, '_labels'):
            ListIO.dictify(list(obj.outV._labels), writer, to_extend, True, False)
        else:
            ListIO.dictify([obj.outV.label], writer, to_extend, True, False)
        to_extend.extend(NULL_BYTES)
        to_extend.extend(NULL_BYTES)

        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader, cls._read_edge, nullable)

    @classmethod
    def _read_edge(cls, b, r):
        edgeid = r.read_object(b)
        # reading label list according to GraphBinaryV4
        edge_labels = r.to_object(b, DataType.list, False)
        inv_id = r.read_object(b)
        inv_labels = r.to_object(b, DataType.list, False)
        inv = Vertex(inv_id, labels=inv_labels)
        outv_id = r.read_object(b)
        outv_labels = r.to_object(b, DataType.list, False)
        outv = Vertex(outv_id, labels=outv_labels)
        b.read(2)
        props = r.read_object(b)
        # null properties are returned as empty lists
        properties = [] if props is None else props
        edge = Edge(edgeid, outv, edge_labels[0] if edge_labels else "edge", inv, properties, labels=edge_labels)
        return edge


class PathIO(_GraphBinaryTypeIO):

    python_type = Path
    graphbinary_type = DataType.path

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
        writer.to_dict(obj.labels, to_extend)
        writer.to_dict(obj.objects, to_extend)
        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader, lambda b, r: Path(r.read_object(b), r.read_object(b)), nullable)


class TreeIO(_GraphBinaryTypeIO):

    python_type = Tree
    graphbinary_type = DataType.tree

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        # when as_value (a nested/bare child tree) prefix_bytes writes nothing:
        # no type-id and no null flag. As a root value it writes {type-id}{null flag}.
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
        root_nodes = obj.root_nodes()
        to_extend.extend(int32_pack(len(root_nodes)))
        for key in root_nodes:
            child = obj.child_at(key)
            # key is written fully-qualified (its own type-id + null flag + value)
            writer.to_dict(key, to_extend)
            # child is written as a BARE tree value: no type-id, no null flag
            cls.dictify(child, writer, to_extend, as_value=True, nullable=False)
        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader, cls._read_tree, nullable)

    @classmethod
    def _read_tree(cls, b, r):
        size = cls.read_int(b)
        tree = Tree()
        while size > 0:
            key = r.read_object(b)
            child = cls.objectify(b, r, False)
            tree.get_or_create_child(key).add_tree(child)
            size = size - 1
        return tree


class PropertyIO(_GraphBinaryTypeIO):

    python_type = Property
    graphbinary_type = DataType.property

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
        StringIO.dictify(obj.key, writer, to_extend, True, False)
        writer.to_dict(obj.value, to_extend)
        to_extend.extend(NULL_BYTES)
        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader, cls._read_property, nullable)

    @classmethod
    def _read_property(cls, b, r):
        p = Property(r.to_object(b, DataType.string, False), r.read_object(b), None)
        b.read(2)
        return p


class TinkerGraphIO(_GraphBinaryTypeIO):

    python_type = Graph
    graphbinary_type = DataType.graph

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)

        vertices = list(obj.vertices.values())
        edges = list(obj.edges.values())

        IntIO.dictify(len(vertices), writer, to_extend, True, False)
        for v in vertices:
            writer.to_dict(v.id, to_extend)
            if hasattr(v, '_labels'):
                ListIO.dictify(list(v._labels), writer, to_extend, True, False)
            else:
                ListIO.dictify([v.label], writer, to_extend, True, False)
            v_props = v.properties
            IntIO.dictify(len(v_props), writer, to_extend, True, False)
            for vp in v_props:
                writer.to_dict(vp.id, to_extend)
                ListIO.dictify([vp.label], writer, to_extend, True, False)
                writer.to_dict(vp.value, to_extend)
                writer.to_dict(None, to_extend)
                ListIO.dictify(vp.properties, writer, to_extend, True, False)

        IntIO.dictify(len(edges), writer, to_extend, True, False)
        for e in edges:
            writer.to_dict(e.id, to_extend)
            if hasattr(e, '_labels'):
                ListIO.dictify(list(e._labels), writer, to_extend, True, False)
            else:
                ListIO.dictify([e.label], writer, to_extend, True, False)
            writer.to_dict(e.inV.id, to_extend)
            writer.to_dict(None, to_extend)
            writer.to_dict(e.outV.id, to_extend)
            writer.to_dict(None, to_extend)
            writer.to_dict(None, to_extend)
            ListIO.dictify(e.properties, writer, to_extend, True, False)

        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader, cls._read_graph, nullable)

    @classmethod
    def _read_graph(cls, b, r):
        graph = Graph()
        vertex_count = r.to_object(b, DataType.int, False)
        for _ in range(vertex_count):
            v_id = r.read_object(b)
            v_labels = r.to_object(b, DataType.list, False)
            vertex = Vertex(v_id, v_labels[0] if v_labels else "vertex", labels=v_labels)
            graph.vertices[v_id] = vertex

            vp_count = r.to_object(b, DataType.int, False)
            for _ in range(vp_count):
                vp_id = r.read_object(b)
                vp_label = r.to_object(b, DataType.list, False)[0]
                vp_value = r.read_object(b)
                r.read_object(b)  # discard parent
                vp = VertexProperty(vp_id, vp_label, vp_value, vertex)
                vertex.properties.append(vp)

                meta_props = r.to_object(b, DataType.list, False)
                if meta_props:
                    vp.properties.extend(meta_props)

        edge_count = r.to_object(b, DataType.int, False)
        for _ in range(edge_count):
            e_id = r.read_object(b)
            e_labels = r.to_object(b, DataType.list, False)
            in_v_id = r.read_object(b)
            r.read_object(b)  # discard in-v label
            out_v_id = r.read_object(b)
            r.read_object(b)  # discard out-v label
            r.read_object(b)  # discard parent

            edge = Edge(e_id, graph.vertices[out_v_id], e_labels[0] if e_labels else "edge",
                        graph.vertices[in_v_id], labels=e_labels)
            graph.edges[e_id] = edge

            edge_props = r.to_object(b, DataType.list, False)
            if edge_props:
                edge.properties.extend(edge_props)

        return graph


class VertexIO(_GraphBinaryTypeIO):

    python_type = Vertex
    graphbinary_type = DataType.vertex

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
        writer.to_dict(obj.id, to_extend)
        # serializing labels as list according to GraphBinaryV4
        if hasattr(obj, '_labels'):
            ListIO.dictify(list(obj._labels), writer, to_extend, True, False)
        else:
            ListIO.dictify([obj.label], writer, to_extend, True, False)
        to_extend.extend(NULL_BYTES)
        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader, cls._read_vertex, nullable)

    @classmethod
    def _read_vertex(cls, b, r):
        vertex_id = r.read_object(b)
        # reading label list according to GraphBinaryV4
        vertex_labels = r.to_object(b, DataType.list, False)
        props = r.read_object(b)
        # null properties are returned as empty lists
        properties = [] if props is None else props
        vertex = Vertex(vertex_id, properties=properties, labels=vertex_labels)
        return vertex


class VertexPropertyIO(_GraphBinaryTypeIO):

    python_type = VertexProperty
    graphbinary_type = DataType.vertexproperty

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
        writer.to_dict(obj.id, to_extend)
        # serializing label as list here for now according to GraphBinaryV4
        ListIO.dictify([obj.label], writer, to_extend, True, False)
        writer.to_dict(obj.value, to_extend)
        to_extend.extend(NULL_BYTES)
        to_extend.extend(NULL_BYTES)
        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader, cls._read_vertexproperty, nullable)

    @classmethod
    def _read_vertexproperty(cls, b, r):
        # reading single string value for now according to GraphBinaryV4
        vp = VertexProperty(r.read_object(b), r.to_object(b, DataType.list, False)[0], r.read_object(b), None)
        b.read(2)
        properties = r.read_object(b)
        # null properties are returned as empty lists
        vp.properties = [] if properties is None else properties
        return vp


class _EnumIO(_GraphBinaryTypeIO):

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
        StringIO.dictify(SymbolUtil.to_camel_case(str(obj.name)), writer, to_extend)
        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader, cls._read_enumval, nullable)

    @classmethod
    def _read_enumval(cls, b, r):
        enum_name = r.to_object(b)
        return cls.python_type[SymbolUtil.to_snake_case(enum_name)]


class DirectionIO(_EnumIO):
    graphbinary_type = DataType.direction
    python_type = Direction

    @classmethod
    def _read_enumval(cls, b, r):
        # Direction needs to retain all CAPS. note that to_/from_ are really just aliases of IN/OUT
        # so they don't need to be accounted for in serialization
        enum_name = r.to_object(b)
        return cls.python_type[enum_name]


class TIO(_EnumIO):
    graphbinary_type = DataType.t
    python_type = T


class MergeIO(_EnumIO):
    graphbinary_type = DataType.merge
    python_type = Merge


class ByteIO(_GraphBinaryTypeIO):
    python_type = SingleByte
    graphbinary_type = DataType.byte

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
        to_extend.extend(int8_pack(obj))
        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader,
                           lambda b, r: int.__new__(SingleByte, int8_unpack(b.read(1))),
                           nullable)


class BinaryIO(_GraphBinaryTypeIO):
    python_type = bytes
    graphbinary_type = DataType.binary

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
        to_extend.extend(int32_pack(len(obj)))
        to_extend.extend(obj)
        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader, cls._read_bytebuffer, nullable)

    @classmethod
    def _read_bytebuffer(cls, b, r):
        size = cls.read_int(b)
        return bytes(b.read(size))


class BooleanIO(_GraphBinaryTypeIO):
    python_type = bool
    graphbinary_type = DataType.boolean

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
        to_extend.extend(int8_pack(0x01 if obj else 0x00))
        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader,
                           lambda b, r: True if int8_unpack(b.read(1)) == 0x01 else False,
                           nullable)


class DurationIO(_GraphBinaryTypeIO):
    python_type = timedelta
    graphbinary_type = DataType.duration

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
        LongIO.dictify(obj.seconds, writer, to_extend, True, False)
        IntIO.dictify(obj.microseconds * 1000, writer, to_extend, True, False)
        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader, cls._read_duration, nullable)
    
    @classmethod
    def _read_duration(cls, b, r):
        seconds = r.to_object(b, DataType.long, False)
        nanos = r.to_object(b, DataType.int, False)
        return timedelta(seconds=seconds, microseconds=nanos / 1000)


class MarkerIO(_GraphBinaryTypeIO):
    python_type = Marker
    graphbinary_type = DataType.marker

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
        to_extend.extend(int8_pack(obj.get_value()))
        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader,
                           lambda b, r: Marker.of(int8_unpack(b.read(1))),
                           nullable)


class CompositePDTIO(_GraphBinaryTypeIO):
    python_type = CompositePDT
    graphbinary_type = DataType.composite_pdt

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
        StringIO.dictify(obj.name, writer, to_extend)
        MapIO.dictify(obj.fields, writer, to_extend)
        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader, cls._read_pdt, nullable)

    @classmethod
    def _read_pdt(cls, b, r):
        name = r.read_object(b)
        fields = r.read_object(b)
        return CompositePDT(name, fields)


class PrimitivePDTIO(_GraphBinaryTypeIO):
    python_type = PrimitivePDT
    graphbinary_type = DataType.primitive_pdt

    @classmethod
    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
        StringIO.dictify(obj.name, writer, to_extend)
        StringIO.dictify(obj.value, writer, to_extend)
        return to_extend

    @classmethod
    def objectify(cls, buff, reader, nullable=True):
        return cls.is_null(buff, reader, cls._read_primitive_pdt, nullable)

    @classmethod
    def _read_primitive_pdt(cls, b, r):
        name = r.read_object(b)
        value = r.read_object(b)
        return PrimitivePDT(name, value)
