""" The definition of the VLR Header, VLR, the KnownVLRs
 are in this module.

 A KnownVLR is a VLR for which we know how to parse its record_data
"""
import abc
import ctypes
import struct
from abc import abstractmethod

from .rawvlr import NULL_BYTE, BaseVLR, VLR
from ..extradims import get_type_for_extra_dim, get_signedness_for_extra_dim, DimensionSignedness

class IKnownVLR(abc.ABC):
    """ Interface that any KnownVLR must implement.
    A KnownVLR is a VLR for which we know how to parse its record_data

    Implementing this interfaces allows to automatically call the
    right parser for the right VLR when reading them.
    """

    @staticmethod
    @abstractmethod
    def official_user_id():
        """ Shall return the official user_id as described in the documentation
        """
        pass

    @staticmethod
    @abstractmethod
    def official_record_ids():
        """ Shall return the official record_id for the VLR

        .. note::

            Even if the VLR has one record_id, the return type must be a tuple

        Returns
        -------
        tuple of int
            The record_ids this VLR type can have
        """
        pass

    @abstractmethod
    def record_data_bytes(self):
        """ Shall return the bytes corresponding to the record_data part of the VLR
        as they should be written in the file.

        Returns
        -------
        bytes
            The bytes of the vlr's record_data

        """
        pass

    @abstractmethod
    def parse_record_data(self, record_data):
        """ Shall parse the given record_data into a user-friendlier structure

        Parameters
        ----------
        record_data: bytes
            The record_data bytes read from the file

        """
        pass

class BaseKnownVLR(BaseVLR, IKnownVLR):
    """ Base Class to factorize common code between the different type of Known VLRs
    """

    def __init__(self, record_id=None, description=""):
        super().__init__(
            self.official_user_id(),
            self.official_record_ids()[0] if record_id is None else record_id,
            description,
        )

    @classmethod
    def from_raw(cls, raw):
        vlr = cls()
        vlr.description = raw.header.description.decode("ascii")
        vlr.parse_record_data(raw.record_data)
        return vlr

class ClassificationLookupVlr(BaseKnownVLR):
    """ This vlr maps class numbers to short descriptions / names

    >>> lookup = ClassificationLookupVlr()
    >>> lookup[0] = "never_classified"
    >>> lookup[2] = "ground"
    >>> lookup[0]
    'never_classified'
    """
    _lookup_struct = struct.Struct("<B15s")

    def __init__(self):
        super().__init__(description="Classification Lookup")
        self.lookups = {}

    def parse_record_data(self, record_data):
        if len(record_data) % self._lookup_struct.size != 0:
            raise ValueError(
                "Length of ClassificationLookup VLR's record_data must be a multiple of {}".format(
                    self._lookup_struct.size
                )
            )

        for i in range(len(record_data) // self._lookup_struct.size):
            class_id, desc = self._lookup_struct.unpack_from(
                record_data, self._lookup_struct.size * i
            )
            self.lookups[class_id] = desc.split(b"\x00")[0].decode("ascii")

    def record_data_bytes(self):
        def lookup_converter(lookup_dict):
            for class_id, description in lookup_dict.items():
                description_bytes = description.encode("ascii")
                if len(description_bytes) > 15:
                    raise ValueError(
                        "decription ({}) is to long ({} bytes), it must not exceed 15 bytes when encoded".format(
                            description, len(description_bytes)
                        )
                    )
                yield class_id, description_bytes

        return b"".join(
            self._lookup_struct.pack(class_id, desc)
            for class_id, desc in lookup_converter(self.lookups)
        )

    def __getitem__(self, class_id):
        return self.lookups[class_id]

    def __setitem__(self, class_id, description):
        if class_id not in range(256):
            raise ValueError("Class id {} is not in range [0, 255]".format(class_id))

        self.lookups[class_id] = description

    @staticmethod
    def official_user_id():
        return "LASF_Spec"

    @staticmethod
    def official_record_ids():
        return (0,)

class LasZipVlr(BaseKnownVLR):
    """ Contains the informations needed by laszip & lazperf
    to compress the point records.
    """

    def __init__(self, data):
        super().__init__(description="http://laszip.org")
        self.record_data = data

    def parse_record_data(self, record_data):
        # Only laz-perf/laszip knows how to parse this
        pass

    def record_data_bytes(self):
        return self.record_data

    @staticmethod
    def official_user_id():
        return "laszip encoded"

    @staticmethod
    def official_record_ids():
        return (22204,)

    @classmethod
    def from_raw(cls, raw_vlr):
        return cls(raw_vlr.record_data)

class ExtraBytesStruct(ctypes.LittleEndianStructure):
    _pack_ = 1
    _fields_ = [
        ("reserved", ctypes.c_uint8 * 2),
        ("data_type", ctypes.c_uint8),
        ("options", ctypes.c_uint8),
        ("name", ctypes.c_char * 32),
        ("unused", ctypes.c_uint8 * 4),
        ("_no_data", (ctypes.c_byte * 8) * 3),
        ("_min", (ctypes.c_byte * 8) * 3),
        ("_max", (ctypes.c_byte * 8) * 3),
        ("_scale", (ctypes.c_byte * 8) * 3),
        ("_offset", (ctypes.c_byte * 8) * 3),
        ("description", ctypes.c_char * 32),
    ]

    _uint64t_struct = struct.Struct("<Q")
    _int64t_struct = struct.Struct("<q")
    _double_struct = struct.Struct("<d")

    def _struct_parser_for_type_signedness(self):
        signedness = get_signedness_for_extra_dim(self.data_type)

        if signedness == DimensionSignedness.FLOATING:
            return self._double_struct
        elif signedness == DimensionSignedness.SIGNED:
            return self._int64t_struct
        elif signedness == DimensionSignedness.UNSIGNED:
            return self._uint64t_struct
        else:
            return None

    def _parse_special_property(self, name):
        strct = self._struct_parser_for_type_signedness()
        return tuple(strct.unpack(d)[0] for d in getattr(self, name))

    @property
    def no_data(self):
        return self._parse_special_property("_no_data")

    @property
    def min(self):
        return self._parse_special_property("_min")

    @property
    def max(self):
        return self._parse_special_property("_max")

    @property
    def offset(self):
        return self._parse_special_property("_offset")

    @property
    def scale(self):
        return self._parse_special_property("_scale")

    def format_name(self):
        return self.name.rstrip(NULL_BYTE).decode().replace(" ", "_").replace("-", "_")

    def type_tuple(self):
        if self.data_type == 0:
            return self.format_name(), "{}u1".format(self.options)
        return self.format_name(), get_type_for_extra_dim(self.data_type)

    @staticmethod
    def size():
        return ctypes.sizeof(ExtraBytesStruct)

    def __repr__(self):
        return "<ExtraBytesStruct({}, {}, {})>".format(
            *self.type_tuple(), self.description
        )

class ExtraBytesVlr(BaseKnownVLR):
    def __init__(self):
        super().__init__(description="Extra Bytes Record")
        self.extra_bytes_structs = []

    def parse_record_data(self, data):
        if (len(data) % ExtraBytesStruct.size()) != 0:
            raise ValueError(
                "Data length of ExtraBytes vlr must be a multiple of {}".format(
                    ExtraBytesStruct.size()
                )
            )
        num_extra_bytes_structs = len(data) // ExtraBytesStruct.size()
        self.extra_bytes_structs = [None] * num_extra_bytes_structs
        for i in range(num_extra_bytes_structs):
            self.extra_bytes_structs[i] = ExtraBytesStruct.from_buffer_copy(
                data[ExtraBytesStruct.size() * i : ExtraBytesStruct.size() * (i + 1)]
            )

    def record_data_bytes(self):
        return b"".join(
            bytes(extra_struct) for extra_struct in self.extra_bytes_structs
        )

    def type_of_extra_dims(self):
        return [extra_dim.type_tuple() for extra_dim in self.extra_bytes_structs]

    def __repr__(self):
        return "<ExtraBytesVlr(extra bytes structs: {})>".format(
            len(self.extra_bytes_structs)
        )

    @staticmethod
    def official_user_id():
        return "LASF_Spec"

    @staticmethod
    def official_record_ids():
        return (4,)

class WaveformPacketStruct(ctypes.LittleEndianStructure):
    _pack_ = 1
    _fields_ = [
        ("bits_per_sample", ctypes.c_ubyte),
        ("waveform_compression_type", ctypes.c_ubyte),
        ("number_of_samples", ctypes.c_uint32),
        ("temporal_sample_spacing", ctypes.c_uint32),
        ("digitizer_gain", ctypes.c_double),
        ("digitizer_offset", ctypes.c_double),
    ]

    @staticmethod
    def size():
        return ctypes.sizeof(WaveformPacketStruct)

class WaveformPacketVlr(BaseKnownVLR):
    def __init__(self, record_id, description=""):
        super().__init__(record_id=record_id, description=description)
        self.parsed_record = None

    def parse_record_data(self, record_data):
        self.parsed_record = WaveformPacketStruct.from_buffer_copy(record_data)

    def record_data_bytes(self):
        return bytes(self.parsed_record)

    @staticmethod
    def official_record_ids():
        return range(100, 356)

    @staticmethod
    def official_user_id():
        return "LASF_Spec"

    @classmethod
    def from_raw(cls, raw_vlr):
        vlr = cls(
            raw_vlr.header.record_id, description=raw_vlr.header.description.decode()
        )
        vlr.description = raw_vlr.header.description
        vlr.parse_record_data(raw_vlr.record_data)
        return vlr

class GeoKeyEntryStruct(ctypes.LittleEndianStructure):
    _pack_ = 1
    _fields_ = [
        ("id", ctypes.c_uint16),
        ("tiff_tag_location", ctypes.c_uint16),
        ("count", ctypes.c_uint16),
        ("value_offset", ctypes.c_uint16),
    ]

    @staticmethod
    def size():
        return ctypes.sizeof(GeoKeysHeaderStructs)

    def __repr__(self):
        return "<GeoKey(Id: {}, Location: {}, count: {}, offset: {})>".format(
            self.id, self.tiff_tag_location, self.count, self.value_offset
        )

class GeoKeysHeaderStructs(ctypes.LittleEndianStructure):
    _pack_ = 1
    _fields_ = [
        ("key_direction_version", ctypes.c_uint16),
        ("key_revision", ctypes.c_uint16),
        ("minor_revision", ctypes.c_uint16),
        ("number_of_keys", ctypes.c_uint16),
    ]

    def __init__(self):
        super().__init__(
            key_directory_version=1, key_revision=1, minor_revision=0, number_of_kets=0
        )

    @staticmethod
    def size():
        return ctypes.sizeof(GeoKeysHeaderStructs)

    def __repr__(self):
        return "<GeoKeysHeader(vers: {}, rev:{}, minor: {}, num_keys: {})>".format(
            self.key_direction_version,
            self.key_revision,
            self.minor_revision,
            self.number_of_keys,
        )

class GeoKeyDirectoryVlr(BaseKnownVLR):
    def __init__(self):
        super().__init__(description="GeoTIFF GeoKeyDirectoryTag")
        self.geo_keys_header = GeoKeysHeaderStructs()
        self.geo_keys = [GeoKeyEntryStruct()]

    def parse_record_data(self, record_data):
        record_data = bytearray(record_data)
        header_data = record_data[: ctypes.sizeof(GeoKeysHeaderStructs)]
        self.geo_keys_header = GeoKeysHeaderStructs.from_buffer(header_data)
        self.geo_keys = []
        keys_data = record_data[GeoKeysHeaderStructs.size() :]
        num_keys = (
            len(record_data[GeoKeysHeaderStructs.size() :]) // GeoKeyEntryStruct.size()
        )
        if num_keys != self.geo_keys_header.number_of_keys:
            # print("Mismatch num keys")
            self.geo_keys_header.number_of_keys = num_keys

        for i in range(self.geo_keys_header.number_of_keys):
            data = keys_data[
                (i * GeoKeyEntryStruct.size()) : (i + 1) * GeoKeyEntryStruct.size()
            ]
            self.geo_keys.append(GeoKeyEntryStruct.from_buffer(data))

    def record_data_bytes(self):
        b = bytes(self.geo_keys_header)
        b += b"".join(map(bytes, self.geo_keys))
        return b

    def __repr__(self):
        return "<{}({} geo_keys)>".format(self.__class__.__name__, len(self.geo_keys))

    @staticmethod
    def official_user_id():
        return "LASF_Projection"

    @staticmethod
    def official_record_ids():
        return (34735,)

class GeoDoubleParamsVlr(BaseKnownVLR):
    def __init__(self):
        super().__init__(description="GeoTIFF GeoDoubleParamsTag")
        self.doubles = []

    def parse_record_data(self, record_data):
        sizeof_double = ctypes.sizeof(ctypes.c_double)
        if len(record_data) % sizeof_double != 0:
            raise ValueError(
                "GeoDoubleParams record data length () is not a multiple of sizeof(double) ()".format(
                    len(record_data), sizeof_double
                )
            )
        record_data = bytearray(record_data)
        num_doubles = len(record_data) // sizeof_double
        for i in range(num_doubles):
            b = record_data[i * sizeof_double : (i + 1) * sizeof_double]
            self.doubles.append(ctypes.c_double.from_buffer(b))

    def record_data_bytes(self):
        return b"".join(map(bytes, self.doubles))

    def __repr__(self):
        return "<GeoDoubleParamsVlr({})>".format(self.doubles)

    @staticmethod
    def official_user_id():
        return "LASF_Projection"

    @staticmethod
    def official_record_ids():
        return (34736,)

class GeoAsciiParamsVlr(BaseKnownVLR):
    def __init__(self):
        super().__init__(description="GeoTIFF GeoAsciiParamsTag")
        self.strings = []

    def parse_record_data(self, record_data):
        self.strings = [s.decode("ascii") for s in record_data.split(NULL_BYTE)]

    def record_data_bytes(self):
        return NULL_BYTE.join(s.encode("ascii") for s in self.strings)

    def __repr__(self):
        return "<GeoAsciiParamsVlr({})>".format(self.strings)

    @staticmethod
    def official_user_id():
        return "LASF_Projection"

    @staticmethod
    def official_record_ids():
        return (34737,)

class WktMathTransformVlr(BaseKnownVLR):
    """
    From the Spec:
        Note that the math transform WKT record is added for completeness, and a coordinate system WKT
        may or may not require a math transform WKT record

    """

    def __init__(self):
        super().__init__(description="")
        self.string = ""

    def _encode_string(self):
        return self.string.encode("utf-8") + NULL_BYTE

    def parse_record_data(self, record_data):
        self.string = record_data.decode("utf-8")

    def record_data_bytes(self):
        return self._encode_string()

    @staticmethod
    def official_user_id():
        return "LASF_Projection"

    @staticmethod
    def official_record_ids():
        return (2112,)

class WktCoordinateSystemVlr(BaseKnownVLR):
    """ Replaces Coordinates Reference System for new las files (point fmt >= 5)
    "LAS is not using the “ESRI WKT”
    """

    def __init__(self, wkt_string=""):
        super().__init__(description="OGC Transformation Record")
        self.string = wkt_string

    def _encode_string(self):
        return self.string.encode("utf-8") + NULL_BYTE

    def parse_record_data(self, record_data):
        self.string = record_data.decode("utf-8")

    def record_data_bytes(self):
        return self._encode_string()

    @staticmethod
    def official_user_id():
        return "LASF_Projection"

    @staticmethod
    def official_record_ids():
        return (2112,)

def vlr_factory(raw_vlr):
    """ Given a raw_vlr tries to find its corresponding KnownVLR class
    that can parse its data.
    If no KnownVLR implementation is found, returns a VLR (record_data will still be bytes)
    """
    user_id = raw_vlr.header.user_id.rstrip(NULL_BYTE).decode()
    known_vlrs = BaseKnownVLR.__subclasses__()
    for known_vlr in known_vlrs:
        if (
            known_vlr.official_user_id() == user_id
            and raw_vlr.header.record_id in known_vlr.official_record_ids()
        ):
            return known_vlr.from_raw(raw_vlr)
    else:
        return VLR.from_raw(raw_vlr)


""" 'Entry point' of the library, Contains the various functions meant to be
used directly by a user
"""
import copy
import io
import logging

import numpy as np

from . import headers, utils
from .lasdatas import las12, las14
from .lasmmap import LasMMAP
from .lasreader import LasReader
from .point import dims, record, PointFormat

USE_UNPACKED = False
logger = logging.getLogger(__name__)

def open_las(source, closefd=True):
    """ Opens and reads the header of the las content in the source

        >>> with open_las('pylastests/simple.las') as f:
        ...     print(f.header.point_format_id)
        3

        >>> f = open('pylastests/simple.las', mode='rb')
        >>> with open_las(f, closefd=False) as flas:
        ...     print(flas.header)
        <LasHeader(1.2)>
        >>> f.closed
        False

        >>> f = open('pylastests/simple.las', mode='rb')
        >>> with open_las(f) as flas:
        ...    las = flas.read()
        >>> f.closed
        True

    Parameters
    ----------
    source : str or io.BytesIO
        if source is a str it must be a filename
        a stream if a file object with the methods read, seek, tell

    closefd: bool
        Whether the stream/file object shall be closed, this only work
        when using open_las in a with statement. An exception is raised if
        closefd is specified and the source is a filename

    Returns
    -------
    pylas.lasreader.LasReader

    """
    if isinstance(source, str):
        stream = open(source, mode="rb")
        if not closefd:
            raise ValueError("Cannot use closefd with filename")
    elif isinstance(source, bytes):
        stream = io.BytesIO(source)
    else:
        stream = source
    return LasReader(stream, closefd=closefd)

def read_las(source, closefd=True):
    """ Entry point for reading las data in pylas

    Reads the whole file into memory.

    >>> las = read_las("pylastests/simple.las")
    >>> las.classification
    array([1, 1, 1, ..., 1, 1, 1], dtype=uint8)

    Parameters
    ----------
    source : str or io.BytesIO
        The source to read data from

    closefd: bool
            if True and the source is a stream, the function will close it
            after it is done reading

    Returns
    -------
    pylas.lasdatas.base.LasBase
        The object you can interact with to get access to the LAS points & VLRs
    """
    with open_las(source, closefd=closefd) as reader:
        return reader.read()

def mmap_las(filename):
    """ MMap a file, much like laspy did, very experimental
    not well tested
    """
    return LasMMAP(filename)

def create_from_header(header):
    """ Creates a File from an existing header,
    allocating the array of point according to the provided header.
    The input header is copied.

    Parameters
    ----------
    header : existing header to be used to create the file

    Returns
    -------
    pylas.lasdatas.base.LasBase
    """
    header = copy.copy(header)
    header.point_count = 0
    points = record.PackedPointRecord.empty(PointFormat(header.point_format_id))
    if header.version >= "1.4":
        return las14.LasData(header=header, points=points)
    return las12.LasData(header=header, points=points)

def create_las(*, point_format_id=0, file_version=None):
    """ Function to create a new empty las data object

    .. note::

        If you provide both point_format and file_version
        an exception will be raised if they are not compatible

    >>> las = create_las(point_format_id=6,file_version="1.2")
    Traceback (most recent call last):
     ...
    pylas.errors.PylasError: Point format 6 is not compatible with file version 1.2

    If you provide only the point_format the file_version will automatically
    selected for you.

    >>> las = create_las(point_format_id=0)
    >>> las.header.version == '1.2'
    True

    >>> las = create_las(point_format_id=6)
    >>> las.header.version == '1.4'
    True

    Parameters
    ----------
    point_format_id: int
        The point format you want the created file to have

    file_version: str, optional, default=None
        The las version you want the created las to have

    Returns
    -------
    pylas.lasdatas.base.LasBase
       A new las data object

    """
    if file_version is not None:
        dims.raise_if_version_not_compatible_with_fmt(point_format_id, file_version)
    else:
        file_version = dims.min_file_version_for_point_format(point_format_id)

    header = headers.HeaderFactory.new(file_version)
    header.point_format_id = point_format_id

    if file_version >= "1.4":
        return las14.LasData(header=header)
    return las12.LasData(header=header)

def convert(source_las, *, point_format_id=None, file_version=None):
    """ Converts a Las from one point format to another
    Automatically upgrades the file version if source file version is not compatible with
    the new point_format_id

    convert to point format 0

    >>> las = read_las('pylastests/simple.las')
    >>> las.header.version
    '1.2'
    >>> las = convert(las, point_format_id=0)
    >>> las.header.point_format_id
    0
    >>> las.header.version
    '1.2'

    convert to point format 6, which need version >= 1.4
    then convert back to point format 0, version is not downgraded

    >>> las = read_las('pylastests/simple.las')
    >>> las.header.version
    '1.2'
    >>> las = convert(las, point_format_id=6)
    >>> las.header.point_format_id
    6
    >>> las.header.version
    '1.4'
    >>> las = convert(las, point_format_id=0)
    >>> las.header.version
    '1.4'

    an exception is raised if the requested point format is not compatible
    with the file version

    >>> las = read_las('pylastests/simple.las')
    >>> convert(las, point_format_id=6, file_version='1.2')
    Traceback (most recent call last):
     ...
    pylas.errors.PylasError: Point format 6 is not compatible with file version 1.2

    Parameters
    ----------
    source_las : pylas.lasdatas.base.LasBase
        The source data to be converted

    point_format_id : int, optional
        The new point format id (the default is None, which won't change the source format id)

    file_version : str, optional,
        The new file version. None by default which means that the file_version
        may be upgraded for compatibility with the new point_format. The file version will not
        be downgraded.

    Returns
    -------
        pylas.lasdatas.base.LasBase
    """
    if point_format_id is None:
        point_format_id = source_las.points_data.point_format.id

    if file_version is None:
        file_version = max(
            source_las.header.version,
            dims.min_file_version_for_point_format(point_format_id),
        )
    else:
        file_version = str(file_version)
        dims.raise_if_version_not_compatible_with_fmt(point_format_id, file_version)

    header = headers.HeaderFactory.convert_header(source_las.header, file_version)
    header.point_format_id = point_format_id

    point_format = PointFormat(
        point_format_id, source_las.points_data.point_format.extra_dims
    )
    points = record.PackedPointRecord.from_point_record(
        source_las.points_data, point_format
    )

    try:
        evlrs = source_las.evlrs
    except ValueError:
        evlrs = []

    if file_version >= "1.4":

        las = las14.LasData(
            header=header, vlrs=source_las.vlrs, points=points, evlrs=evlrs
        )
    else:
        if evlrs:
            logger.warning(
                "The source contained {} EVLRs,"
                " they will be lost as version {} doest not support them".format(
                    len(evlrs), file_version
                )
            )
        las = las12.LasData(header=header, vlrs=source_las.vlrs, points=points)

    return las

def merge_las(*las_files):
    """ Merges multiple las files into one

    merged = merge_las(las_1, las_2)
    merged = merge_las([las_1, las_2, las_3])

    Parameters
    ----------
    las_files: Iterable of LasData or LasData

    Returns
    -------
    pylas.lasdatas.base.LasBase
        The result of the merging

    """
    if len(las_files) == 1:
        las_files = las_files[0]

    if not las_files:
        raise ValueError("No files to merge")

    if not utils.files_have_same_dtype(las_files):
        raise ValueError("All files must have the same point format")

    header = las_files[0].header
    num_pts_merged = sum(len(las.points) for las in las_files)

    # scaled x, y, z have to be set manually
    # to be sure to have a good offset in the header
    merged = create_from_header(header)
    # TODO extra dimensions should be manged better here

    for dim_name, dim_type in las_files[0].points_data.point_format.extra_dims:
        merged.add_extra_dim(dim_name, dim_type)

    merged.points = np.zeros(num_pts_merged, merged.points.dtype)
    merged_x = np.zeros(num_pts_merged, np.float64)
    merged_y = np.zeros(num_pts_merged, np.float64)
    merged_z = np.zeros(num_pts_merged, np.float64)

    offset = 0
    for i, las in enumerate(las_files, start=1):
        slc = slice(offset, offset + len(las.points))
        merged.points[slc] = las.points
        merged_x[slc] = las.x
        merged_y[slc] = las.y
        merged_z[slc] = las.z
        merged['point_source_id'][slc] = i
        offset += len(las.points)

    merged.x = merged_x
    merged.y = merged_y
    merged.z = merged_z

    return merged

def write_then_read_again(las, do_compress=False):
    """ writes the given las into memory using BytesIO and
    reads it again, returning the newly read file.

    Mostly used for testing purposes, without having to write to disk
    """
    out = io.BytesIO()
    las.write(out, do_compress=do_compress)
    out.seek(0)
    return read_las(out)


import ctypes
import datetime
import enum
import logging
import uuid

import numpy as np

from .. import compression
from .. import errors

logger = logging.getLogger(__name__)

LAS_FILE_SIGNATURE = b"LASF"
PROJECT_NAME = b"pylas"

class GpsTimeType(enum.IntEnum):
    WEEK_TIME = 0
    STANDARD = 1

class GlobalEncoding(ctypes.LittleEndianStructure):
    _pack_ = 1
    _fields_ = [
        ("_gps_time_type", ctypes.c_uint16, 1),
        ("waveform_internal", ctypes.c_uint16, 1),  # 1.3
        ("waveform_external", ctypes.c_uint16, 1),  # 1.3
        ("synthetic_return_numbers", ctypes.c_uint16, 1),  # 1.3
        ("wkt", ctypes.c_uint16, 1),  # 1.4
        ("reserved", ctypes.c_uint16, 11),
    ]

    def are_waveform_flag_equal(self):
        return self.waveform_internal == self.waveform_external

    @property
    def gps_time_type(self):
        if self._gps_time_type:
            return GpsTimeType.STANDARD
        else:
            return GpsTimeType.WEEK_TIME

    @gps_time_type.setter
    def gps_time_type(self, value):
        self._gps_time_type = value

class RawHeader1_1(ctypes.LittleEndianStructure):
    _version_ = "1.1"
    _pack_ = 1
    _fields_ = [
        ("file_signature", ctypes.c_char * 4),
        ("file_source_id", ctypes.c_uint16),
        ("global_encoding", GlobalEncoding),
        ("uuid_bytes", ctypes.c_ubyte * 16),
        ("version_major", ctypes.c_uint8),
        ("version_minor", ctypes.c_uint8),
        ("system_identifier", ctypes.c_char * 32),
        ("generating_software", ctypes.c_char * 32),
        ("creation_day_of_year", ctypes.c_uint16),
        ("creation_year", ctypes.c_uint16),
        ("size", ctypes.c_uint16),
        ("offset_to_point_data", ctypes.c_uint32),
        ("number_of_vlr", ctypes.c_uint32),
        ("_point_data_format_id", ctypes.c_uint8),
        ("point_data_record_length", ctypes.c_uint16),
        ("legacy_point_count", ctypes.c_uint32),
        ("legacy_number_of_points_by_return", ctypes.c_uint32 * 5),
        ("x_scale", ctypes.c_double),
        ("y_scale", ctypes.c_double),
        ("z_scale", ctypes.c_double),
        ("x_offset", ctypes.c_double),
        ("y_offset", ctypes.c_double),
        ("z_offset", ctypes.c_double),
        ("x_max", ctypes.c_double),
        ("x_min", ctypes.c_double),
        ("y_max", ctypes.c_double),
        ("y_min", ctypes.c_double),
        ("z_max", ctypes.c_double),
        ("z_min", ctypes.c_double),
    ]

    def __init__(self):
        super().__init__(
            file_signature=LAS_FILE_SIGNATURE,
            version=self._version_,
            generating_software=PROJECT_NAME,
            size=LAS_HEADERS_SIZE[self._version_],
            offset_to_point_data=LAS_HEADERS_SIZE[self._version_],
            x_scale=0.001,
            y_scale=0.001,
            z_scale=0.001,
        )
        self.date = datetime.date.today()

    @property
    def point_count(self):
        """ Returns the number of points in the file
        """
        return self.legacy_point_count

    @point_count.setter
    def point_count(self, value):
        self.legacy_point_count = value

    @property
    def number_of_points_by_return(self):
        return self.legacy_number_of_points_by_return

    @number_of_points_by_return.setter
    def number_of_points_by_return(self, value):
        if len(value) > 5:
            logger.warning(
                "Received return numbers up to {}, truncating to 5 for header.".format(
                    len(value)
                )
            )
        self.legacy_number_of_points_by_return = tuple(value[:5])

    @property
    def version(self):
        """
        .. DANGER::
            You should not use this directly if you want to change/convert
            the header version as this function does not change the underlying
            size of bytes buffer.
            See :meth:`HeaderFactory.convert_header` to convert headers

        """
        return "{}.{}".format(self.version_major, self.version_minor)

    @version.setter
    def version(self, new_version):
        try:
            self.size = LAS_HEADERS_SIZE[str(new_version)]
            self.offset_to_point_data = self.size
        except KeyError:
            raise errors.FileVersionNotSupported(new_version)
        self.version_major, self.version_minor = map(int, new_version.split("."))

    @property
    def date(self):
        """ Returns the creation date stored in the las file

        Returns
        -------
        datetime.date

        """
        try:
            return datetime.date(self.creation_year, 1, 1) + datetime.timedelta(
                self.creation_day_of_year - 1
            )
        except ValueError:
            return None

    @date.setter
    def date(self, date):
        """ Returns the date of file creation as a python date object
        """
        self.creation_year = date.year
        self.creation_day_of_year = date.timetuple().tm_yday

    @property
    def point_format_id(self):
        return compression.compressed_id_to_uncompressed(self._point_data_format_id)

    @point_format_id.setter
    def point_format_id(self, value):
        """ Returns the point format id of the points
        """
        self._point_data_format_id = value

    @property
    def point_size(self):
        """ Returns the number of bits each point takes
        """
        return self.point_data_record_length

    @point_size.setter
    def point_size(self, value):
        self.point_data_record_length = value

    @property
    def uuid(self):
        return uuid.UUID(bytes_le=bytes(self.uuid_bytes))

    @uuid.setter
    def uuid(self, new_uuid):
        self.uuid_bytes = (ctypes.c_ubyte * 16)(*new_uuid.bytes_le)

    @property
    def are_points_compressed(self):
        """ Returns True if the point_format_id indicates that the points are stored compressed
        """
        return compression.is_point_format_compressed(self._point_data_format_id)

    @property
    def mins(self):
        """ Returns de minimum values of x, y, z as a numpy array
        """
        return np.array([self.x_min, self.y_min, self.z_min])

    @mins.setter
    def mins(self, value):
        """ Sets de minimum values of x, y, z as a numpy array
        """
        self.x_min, self.y_min, self.z_min = value

    @property
    def maxs(self):
        """ Returns de maximum values of x, y, z as a numpy array
        """
        return np.array([self.x_max, self.y_max, self.z_max])

    @maxs.setter
    def maxs(self, value):
        """ Sets de maximum values of x, y, z as a numpy array
        """
        self.x_max, self.y_max, self.z_max = value

    @property
    def scales(self):
        """ Returns the scaling values of x, y, z as a numpy array
        """
        return np.array([self.x_scale, self.y_scale, self.z_scale])

    @scales.setter
    def scales(self, value):
        self.x_scale, self.y_scale, self.z_scale = value

    @property
    def offsets(self):
        """ Returns the offsets values of x, y, z as a numpy array
        """
        return np.array([self.x_offset, self.y_offset, self.z_offset])

    @offsets.setter
    def offsets(self, value):
        self.x_offset, self.y_offset, self.z_offset = value

    def write_to(self, out_stream):
        out_stream.write(bytes(self))

    def __repr__(self):
        return "<LasHeader({})>".format(self.version)

class RawHeader1_2(RawHeader1_1):
    _version_ = "1.2"

class RawHeader1_3(RawHeader1_2):
    _version_ = "1.3"
    _fields_ = [("start_of_waveform_data_packet_record", ctypes.c_uint64)]

class RawHeader1_4(RawHeader1_3):
    _version_ = "1.4"
    _fields_ = [
        ("start_of_first_evlr", ctypes.c_uint64),
        ("number_of_evlr", ctypes.c_uint32),
        ("point_count", ctypes.c_uint64),
        ("_number_of_points_by_return", ctypes.c_uint64 * 15),
    ]

    @property
    def number_of_points_by_return(self):
        return self._number_of_points_by_return

    @number_of_points_by_return.setter
    def number_of_points_by_return(self, value):
        value = tuple(value)
        self.legacy_number_of_points_by_return = value[:5]
        if len(value) > 15:
            logger.warning(
                "Received return numbers up to {}, truncating to 15 for header.".format(
                    len(value)
                )
            )
        self._number_of_points_by_return = value[:15]

class HeaderFactory:
    """ Factory to create a new header by specifying the version.
    This Factory also handles converting headers between different
    versions.
    """

    _version_to_header = {
        "1.1": RawHeader1_1,
        "1.2": RawHeader1_2,
        "1.3": RawHeader1_3,
        "1.4": RawHeader1_4,
    }
    _offset_to_major_version = RawHeader1_1.version_major.offset

    @classmethod
    def header_class_for_version(cls, version):
        """
        >>> HeaderFactory.header_class_for_version(2.0)
        Traceback (most recent call last):
         ...
        pylas.errors.FileVersionNotSupported: 2.0

        >>> HeaderFactory.header_class_for_version(1.2)
        <class 'pylas.headers.rawheader.RawHeader1_2'>

        >>> header_class = HeaderFactory.header_class_for_version(1.4)
        >>> header_class()
        <LasHeader(1.4)>

        """
        try:
            return cls._version_to_header[str(version)]
        except KeyError:
            raise errors.FileVersionNotSupported(version)

    @classmethod
    def new(cls, version):
        """ Returns a new instance of a header.

        Parameters
        ----------
        version : float or str
            The header version

        >>> HeaderFactory.new(1.4)
        <LasHeader(1.4)>

        >>> HeaderFactory.new('1.2')
        <LasHeader(1.2)>

        """
        return cls.header_class_for_version(version)()

    @classmethod
    def read_from_stream(cls, stream):
        version = cls.peek_file_version(stream)
        header_class = cls.header_class_for_version(version)
        return header_class.from_buffer(
            bytearray(stream.read(ctypes.sizeof(header_class)))
        )

    @classmethod
    def from_mmap(cls, mmap):
        version = cls.peek_file_version(mmap)
        return cls.header_class_for_version(version).from_buffer(mmap)

    @classmethod
    def peek_file_version(cls, stream):
        """ seeks to the position of the las version header fields
        in the stream and returns it as a str

        Parameters
        ----------
        stream io.BytesIO

        Returns
        -------
        str
            file version read from the stream

        """
        old_pos = stream.tell()
        stream.seek(cls._offset_to_major_version)
        major = int.from_bytes(stream.read(ctypes.sizeof(ctypes.c_uint8)), "little")
        minor = int.from_bytes(stream.read(ctypes.sizeof(ctypes.c_uint8)), "little")
        stream.seek(old_pos)
        return "{}.{}".format(major, minor)

    @classmethod
    def convert_header(cls, old_header, new_version):
        """ Converts a header to a another version

        Parameters
        ----------
        old_header: the old header instance
        new_version: float or str

        Returns
        -------
        The converted header

        >>> old_header = HeaderFactory.new(1.2)
        >>> HeaderFactory.convert_header(old_header, 1.4)
        <LasHeader(1.4)>

        >>> old_header = HeaderFactory.new('1.4')
        >>> HeaderFactory.convert_header(old_header, '1.2')
        <LasHeader(1.2)>

        """
        new_header_class = cls.header_class_for_version(new_version)

        b = bytearray(old_header)
        b += b"\x00" * (ctypes.sizeof(new_header_class) - len(b))
        new_header = new_header_class.from_buffer(b)
        new_header.version = str(new_version)

        return new_header

LAS_HEADERS_SIZE = {
    "1.1": ctypes.sizeof(RawHeader1_1),
    "1.2": ctypes.sizeof(RawHeader1_1),
    "1.3": ctypes.sizeof(RawHeader1_3),
    "1.4": ctypes.sizeof(RawHeader1_4),
}


""" This module contains functions to pack and unpack point dimensions
"""
import numpy as np

def least_significant_bit(val):
    """ Return the least significant bit
    """
    return (val & -val).bit_length() - 1

def unpack(source_array, mask, dtype=np.uint8):
    """ Unpack sub field using its mask

    Parameters:
    ----------
    source_array : numpy.ndarray
        The source array
    mask : mask (ie: 0b00001111)
        Mask of the sub field to be extracted from the source array
    Returns
    -------
    numpy.ndarray
        The sub field array
    """
    lsb = least_significant_bit(mask)
    return ((source_array & mask) >> lsb).astype(dtype)

def pack(array, sub_field_array, mask, inplace=False):
    """ Packs a sub field's array into another array using a mask

    Parameters:
    ----------
    array : numpy.ndarray
        The array in which the sub field array will be packed into
    array_in : numpy.ndarray
        sub field array to pack
    mask : mask (ie: 0b00001111)
        Mask of the sub field
    inplace : {bool}, optional
        If true a new array is returned. (the default is False, which modifies the array in place)

    Raises
    ------
    OverflowError
        If the values contained in the sub field array are greater than its mask's number of bits
        allows
    """
    lsb = least_significant_bit(mask)
    max_value = int(mask >> lsb)
    if sub_field_array.max() > max_value:
        raise OverflowError(
            "value ({}) is greater than allowed (max: {})".format(
                sub_field_array.max(), max_value
            )
        )
    if inplace:
        array[:] = array & ~mask
        array[:] = array | ((sub_field_array << lsb) & mask).astype(array.dtype)
    else:
        array = array & ~mask
        return array | ((sub_field_array << lsb) & mask).astype(array.dtype)


import numpy as np

from . import dims
from .. import errors

class PointFormat:
    """ Class that handles all the information about a point format

    Most of the methods/properties will throw a
    pylas.errors.PointFormatNotSupported if the point format id is not supported
    """

    def __init__(self, point_format_id, extra_dims=None):
        """
        Parameters
        ----------
        point_format_id: int
            point format id
        extra_dims: list of tuple
            [(name, type_str), ..] of extra dimensions attached to this point format
        """
        self.id = point_format_id
        if extra_dims is None:
            extra_dims = []
        self.extra_dims = extra_dims

    @property
    def dimension_names(self):
        """ Returns the names of the dimensions contained in the point format

        Returns
        -------
        list of str
            the names of the dimensions defined by this point format

        """
        return self.unpacked_dtype.names

    @property
    def dtype(self):
        """ Returns the numpy.dtype used to store the point records in a numpy array

        .. note::

            The dtype corresponds to the dtype with sub_fields *packed* into their
            composed fields

        """
        dtype = self._access_dict(dims.ALL_POINT_FORMATS_DTYPE, self.id)
        dtype = self._dtype_add_extra_dims(dtype)
        return dtype

    @property
    def unpacked_dtype(self):
        """ Returns the numpy.dtype used to store the point records in a numpy array

        .. note::

            The dtype corresponds to the dtype with sub_fields *unpacked*

        """
        dtype = self._access_dict(dims.UNPACKED_POINT_FORMATS_DTYPES, self.id)
        dtype = self._dtype_add_extra_dims(dtype)
        return dtype

    @property
    def composed_fields(self):
        """ Returns the dict of composed fields defined for the point format

        Returns
        -------
        Dict[str, List[SubFields]]
            maps a composed field name to its sub_fields

        """
        return self._access_dict(dims.COMPOSED_FIELDS, self.id)

    @property
    def sub_fields(self):
        """ Returns a dict of the sub fields for this point format

        Returns
        -------
        Dict[str, Tuple[str, SubField]]
            maps a sub field name to its composed dimension with additional information

        """
        sub_fields_dict = {}
        for composed_dim_name, sub_fields in self.composed_fields.items():
            for sub_field in sub_fields:
                sub_fields_dict[sub_field.name] = (composed_dim_name, sub_field)
        return sub_fields_dict

    @property
    def extra_dimension_names(self):
        """ Returns the list of extra dimensions attached to this point format
        """
        return [extd[0] for extd in self.extra_dims]

    @property
    def num_extra_bytes(self):
        """ Returns the number of extra bytes
        """
        return sum(np.dtype(extra_dim[1]).itemsize for extra_dim in self.extra_dims)

    @property
    def has_waveform_packet(self):
        """ Returns True if the point format has waveform packet dimensions
        """
        dimensions = set(self.dimension_names)
        return all(name in dimensions for name in dims.WAVEFORM_FIELDS_NAMES)

    def dimension_type_info(self, dimension_name):
        return np.iinfo(self.dtype[dimension_name])

    @staticmethod
    def _access_dict(d, key):
        try:
            return d[key]
        except KeyError as e:
            raise errors.PointFormatNotSupported(e)

    def _dtype_add_extra_dims(self, dtype):
        if self.extra_dims:
            descr = dtype.descr
            descr.extend(self.extra_dims)
            dtype = np.dtype(descr)
        return dtype

    def __eq__(self, other):
        if self.id != other.id:
            return False

        if self.extra_dims != other.extra_dims:
            return False

        return True

    def __int__(self):
        return self.id

    def __repr__(self):
        return "<PointFormat({})>".format(self.id)

    def is_supported(self):
        return self.id in dims.ALL_POINT_FORMATS_DIMENSIONS

def lost_dimensions(point_fmt_in, point_fmt_out):
    """  Returns a list of the names of the dimensions that will be lost
    when converting from point_fmt_in to point_fmt_out
    """

    unpacked_dims_in = PointFormat(point_fmt_in).dtype
    unpacked_dims_out = PointFormat(point_fmt_out).dtype

    out_dims = unpacked_dims_out.fields
    completely_lost = []
    for dim_name in unpacked_dims_in.names:
        if dim_name not in out_dims:
            completely_lost.append(dim_name)
    return completely_lost


# -*- coding: utf-8 -*-

"""Console script for satel_integra."""

import logging
import click
from satel_integra import demo

@click.command()
@click.option('--command', default="demo", help='Command on what to do.')
@click.option('--ip', default='192.168.2.230',
              help='Ip address of the ETHM module for SATEL Integra alarm.')
@click.option('--port', default=7094, help='Port number of the Satel Integra.')
@click.option('--loglevel', default='DEBUG', help='Logging level (python names).')
def main(port, ip, command, loglevel):
    """Console script for satel_integra."""
    numeric_level = getattr(logging, loglevel.upper(), None)
    if not isinstance(numeric_level, int):
        raise ValueError('Invalid log level: %s' % loglevel)

    logging.basicConfig(level=numeric_level)

    click.echo("Demo of satel_integra library")
    if command == "demo":
        demo(ip, port)

if __name__ == "__main__":
    main()


# -*- coding: utf-8 -*-

"""Main module."""

import asyncio
import logging
from enum import Enum, unique

_LOGGER = logging.getLogger(__name__)

def checksum(command):
    """Function to calculate checksum as per Satel manual."""
    crc = 0x147A
    for b in command:
        # rotate (crc 1 bit left)
        crc = ((crc << 1) & 0xFFFF) | (crc & 0x8000) >> 15
        crc = crc ^ 0xFFFF
        crc = (crc + (crc >> 8) + b) & 0xFFFF
    return crc

def print_hex(data):
    """Debugging method to print out frames in hex."""
    hex_msg = ""
    for c in data:
        hex_msg += "\\x" + format(c, "02x")
    _LOGGER.debug(hex_msg)

def verify_and_strip(resp):
    """Verify checksum and strip header and footer of received frame."""
    if resp[0:2] != b'\xFE\xFE':
        _LOGGER.error("Houston, we got problem:")
        print_hex(resp)
        raise Exception("Wrong header - got %X%X" % (resp[0], resp[1]))
    if resp[-2:] != b'\xFE\x0D':
        raise Exception("Wrong footer - got %X%X" % (resp[-2], resp[-1]))
    output = resp[2:-2].replace(b'\xFE\xF0', b'\xFE')

    c = checksum(bytearray(output[0:-2]))

    if (256 * output[-2:-1][0] + output[-1:][0]) != c:
        raise Exception("Wrong checksum - got %d expected %d" % (
            (256 * output[-2:-1][0] + output[-1:][0]), c))

    return output[0:-2]

def list_set_bits(r, expected_length):
    """Return list of positions of bits set to one in given data.

    This method is used to read e.g. violated zones. They are marked by ones
    on respective bit positions - as per Satel manual.
    """
    set_bit_numbers = []
    bit_index = 0x1
    assert (len(r) == expected_length + 1)

    for b in r[1:]:
        for i in range(8):
            if ((b >> i) & 1) == 1:
                set_bit_numbers.append(bit_index)
            bit_index += 1

    return set_bit_numbers

def generate_query(command):
    """Add header, checksum and footer to command data."""
    data = bytearray(command)
    c = checksum(data)
    data.append(c >> 8)
    data.append(c & 0xFF)
    data.replace(b'\xFE', b'\xFE\xF0')

    data = bytearray.fromhex("FEFE") + data + bytearray.fromhex("FE0D")
    return data

def output_bytes(output):
    _LOGGER.debug("output_bytes")
    output_no = 1 << output - 1
    return output_no.to_bytes(32, 'little')

def partition_bytes(partition_list):
        ret_val = 0
        for position in partition_list:
            if position >= 32:
                raise IndexError()
            ret_val = ret_val | (1 << (position - 1))

        return ret_val.to_bytes(4, 'little')

@unique
class AlarmState(Enum):
    """Represents status of the alarm."""

    ARMED_MODE0 = 0
    ARMED_MODE1 = 1
    ARMED_MODE2 = 2
    ARMED_MODE3 = 3
    ARMED_SUPPRESSED = 4
    ENTRY_TIME = 5
    EXIT_COUNTDOWN_OVER_10 = 6
    EXIT_COUNTDOWN_UNDER_10 = 7
    TRIGGERED = 8
    TRIGGERED_FIRE = 9
    DISARMED = 10

class AsyncSatel:
    """Asynchronous interface to talk to Satel Integra alarm system."""

    def __init__(self, host, port, loop, monitored_zones=[],
                 monitored_outputs=[], partitions=[]):
        """Init the Satel alarm data."""
        self._host = host
        self._port = port
        self._loop = loop
        self._message_handlers = {}
        self._monitored_zones = monitored_zones
        self.violated_zones = []
        self._monitored_outputs = monitored_outputs
        self.violated_outputs = []
        self.partition_states = {}
        self._keep_alive_timeout = 20
        self._reconnection_timeout = 15
        self._reader = None
        self._writer = None
        self.closed = False
        self._alarm_status_callback = None
        self._zone_changed_callback = None
        self._output_changed_callback = None
        self._partitions = partitions
        self._command_status_event = asyncio.Event()
        self._command_status = False

        self._message_handlers[b'\x00'] = self._zone_violated
        self._message_handlers[b'\x17'] = self._output_changed
        self._message_handlers[b'\x0A'] = lambda msg: self._armed(
            AlarmState.ARMED_MODE0, msg)
        self._message_handlers[b'\x2A'] = lambda msg: self._armed(
            AlarmState.ARMED_MODE1, msg)
        self._message_handlers[b'\x0B'] = lambda msg: self._armed(
            AlarmState.ARMED_MODE2, msg)
        self._message_handlers[b'\x0C'] = lambda msg: self._armed(
            AlarmState.ARMED_MODE3, msg)
        self._message_handlers[b'\x09'] = lambda msg: self._armed(
            AlarmState.ARMED_SUPPRESSED, msg)
        self._message_handlers[b'\x0E'] = lambda msg: self._armed(
            AlarmState.ENTRY_TIME, msg)
        self._message_handlers[b'\x0F'] = lambda msg: self._armed(
            AlarmState.EXIT_COUNTDOWN_OVER_10, msg)
        self._message_handlers[b'\x10'] = lambda msg: self._armed(
            AlarmState.EXIT_COUNTDOWN_UNDER_10, msg)
        self._message_handlers[b'\xEF'] = lambda msg: self._command_result(msg)
        self._message_handlers[b'\x13'] = lambda msg: self._armed(
            AlarmState.TRIGGERED, msg)
        self._message_handlers[b'\x14'] = lambda msg: self._armed(
            AlarmState.TRIGGERED_FIRE, msg)

    @property
    def connected(self):
        """Return true if there is connection to the alarm."""
        return self._writer and self._reader

    async def connect(self):
        """Make a TCP connection to the alarm system."""
        _LOGGER.debug("Connecting...")

        try:
            self._reader, self._writer = await asyncio.open_connection(
                self._host, self._port, loop=self._loop)
            _LOGGER.debug("sucess connecting...")

        except Exception as e:
            _LOGGER.warning(
                "Exception during connecting: %s.", e)
            self._writer = None
            self._reader = None
            return False

        return True

    async def start_monitoring(self):
        """Start monitoring for interesting events."""
        data = generate_query(
            b'\x7F\x01\xDC\x99\x80\x00\x04\x00\x00\x00\x00\x00\x00')

        await self._send_data(data)
        resp = await self._read_data()

        if resp is None:
            _LOGGER.warning("Start monitoring - no data!")
            return

        if resp[1:2] != b'\xFF':
            _LOGGER.warning("Monitoring not accepted.")

    def _zone_violated(self, msg):

        status = {"zones": {}}

        violated_zones = list_set_bits(msg, 32)
        self.violated_zones = violated_zones
        _LOGGER.debug("Violated zones: %s", violated_zones)
        for zone in self._monitored_zones:
            status["zones"][zone] = \
                1 if zone in violated_zones else 0

        _LOGGER.debug("Returning status: %s", status)

        if self._zone_changed_callback:
            self._zone_changed_callback(status)

        return status

    def _output_changed(self, msg):
        """0x17   outputs state 0x17   + 16/32 bytes"""

        status = {"outputs": {}}

        output_states = list_set_bits(msg, 32)
        self.violated_outputs = output_states
        _LOGGER.debug("Output states: %s, monitored outputs: %s",
                      output_states, self._monitored_outputs)
        for output in self._monitored_outputs:
            status["outputs"][output] = \
                1 if output in output_states else 0

        _LOGGER.debug("Returning status: %s", status)

        if self._output_changed_callback:
            self._output_changed_callback(status)

        return status

    def _command_result(self, msg):
        status = {"error": "Some problem!"}
        error_code = msg[1:2]

        if error_code in [b'\x00', b'\xFF']:
            status = {"error": "OK"}
        elif error_code == b'\x01':
            status = {"error": "User code not found"}

        _LOGGER.debug("Received error status: %s", status)
        self._command_status = status
        self._command_status_event.set()
        return status

    # async def send_and_wait_for_answer(self, data):
    #     """Send given data and wait for confirmation from Satel"""
    #     await self._send_data(data)
    #     try:
    #         await asyncio.wait_for(self._command_status_event.wait(),
    #                                timeout=5)
    #     except asyncio.TimeoutError:
    #         _LOGGER.warning("Timeout waiting for reponse from Satel!")
    #     return self._command_status

    async def _send_data(self, data):
        _LOGGER.debug("-- Sending data --")
        print_hex(data)
        _LOGGER.debug("-- ------------- --")
        _LOGGER.debug("Sending %d bytes...", len(data))

        if not self._writer:
            _LOGGER.warning("Ignoring data because we're disconnected!")
            return
        try:
            self._writer.write(data)
            await self._writer.drain()
        except Exception as e:
            _LOGGER.warning(
                "Exception during sending data: %s.", e)
            self._writer = None
            self._reader = None
            return False

    async def arm(self, code, partition_list, mode=0):
        """Send arming command to the alarm. Modes allowed: from 0 till 3."""
        _LOGGER.debug("Sending arm command, mode: %s!", mode)
        while len(code) < 16:
            code += 'F'

        code_bytes = bytearray.fromhex(code)
        mode_command = 0x80 + mode
        data = generate_query(mode_command.to_bytes(1, 'big')
                              + code_bytes
                              + partition_bytes(partition_list))

        await self._send_data(data)

    async def disarm(self, code, partition_list):
        """Send command to disarm."""
        _LOGGER.info("Sending disarm command.")
        while len(code) < 16:
            code += 'F'

        code_bytes = bytearray.fromhex(code)

        data = generate_query(b'\x84' + code_bytes
                              + partition_bytes(partition_list))

        await self._send_data(data)

    async def clear_alarm(self, code, partition_list):
        """Send command to clear the alarm."""
        _LOGGER.info("Sending clear the alarm command.")
        while len(code) < 16:
            code += 'F'

        code_bytes = bytearray.fromhex(code)

        data = generate_query(b'\x85' + code_bytes
                              + partition_bytes(partition_list))

        await self._send_data(data)

    async def set_output(self, code, output_id, state):
        """Send output turn on command to the alarm."""
        """0x88   outputs on
              + 8 bytes - user code
              + 16/32 bytes - output list
              If function is accepted, function result can be
              checked by observe the system state """
        _LOGGER.debug("Turn on, output: %s, code: %s", output_id, code)
        while len(code) < 16:
            code += 'F'

        code_bytes = bytearray.fromhex(code)
        mode_command = 0x88 if state else 0x89
        data = generate_query(mode_command.to_bytes(1, 'big') +
                              code_bytes +
                              output_bytes(output_id))
        await self._send_data(data)

    def _armed(self, mode, msg):
        partitions = list_set_bits(msg, 4)

        _LOGGER.debug("Update: list of partitions in mode %s: %s",
                      mode, partitions)

        self.partition_states[mode] = partitions

        if self._alarm_status_callback:
            self._alarm_status_callback()

    async def _read_data(self):
        if not self._reader:
            return []

        try:
            data = await self._reader.readuntil(b'\xFE\x0D')
            _LOGGER.debug("-- Receiving data --")
            print_hex(data)
            _LOGGER.debug("-- ------------- --")
            return verify_and_strip(data)

        except Exception as e:
            _LOGGER.warning(
                "Got exception: %s. Most likely the other side has "
                "disconnected!", e)
            self._writer = None
            self._reader = None

            if self._alarm_status_callback:
                self._alarm_status_callback()

    async def keep_alive(self):
        """A workaround for Satel Integra disconnecting after 25s.

        Every interval it sends some random question to the device, ignoring
        answer - just to keep connection alive.
        """
        while True:
            await asyncio.sleep(self._keep_alive_timeout)
            if self.closed:
                return
            # Command to read status of the alarm
            data = generate_query(b'\xEE\x01\x01')
            await self._send_data(data)

    async def _update_status(self):
        _LOGGER.debug("Wait...")

        resp = await self._read_data()

        if not resp:
            _LOGGER.warning("Got empty response. We think it's disconnect.")
            self._writer = None
            self._reader = None
            if self._alarm_status_callback:
                self._alarm_status_callback()
            return

        msg_id = resp[0:1]
        str_msg_id = ''.join(format(x, '02x') for x in msg_id)
        if msg_id in self._message_handlers:
            _LOGGER.info("Calling handler for id: 0x%s", str_msg_id)
            self._message_handlers[msg_id](resp)
        else:
            _LOGGER.info("Ignoring message: 0x%s", str_msg_id)

    async def monitor_status(self, alarm_status_callback=None,
                             zone_changed_callback=None,
                             output_changed_callback=None):
        """Start monitoring of the alarm status.

        Send command to satel integra to start sending updates. Read in a
        loop and call respective callbacks when received messages.
        """
        self._alarm_status_callback = alarm_status_callback
        self._zone_changed_callback = zone_changed_callback
        self._output_changed_callback = output_changed_callback

        _LOGGER.info("Starting monitor_status loop")

        while not self.closed:
            _LOGGER.debug("Iteration... ")
            while not self.connected:
                _LOGGER.info("Not connected, re-connecting... ")
                await self.connect()
                if not self.connected:
                    _LOGGER.warning("Not connected, sleeping for 10s... ")
                    await asyncio.sleep(self._reconnection_timeout)
                    continue
            await self.start_monitoring()
            if not self.connected:
                _LOGGER.warning("Start monitoring failed, sleeping for 10s...")
                await asyncio.sleep(self._reconnection_timeout)
                continue
            while True:
                await self._update_status()
                _LOGGER.debug("Got status!")
                if not self.connected:
                    _LOGGER.info("Got connection broken, reconnecting!")
                    break
        _LOGGER.info("Closed, quit monitoring.")

    def close(self):
        """Stop monitoring and close connection."""
        _LOGGER.debug("Closing...")
        self.closed = True
        if self.connected:
            self._writer.close()

def demo(host, port):
    """Basic demo of the monitoring capabilities."""
    # logging.basicConfig(level=logging.DEBUG)

    loop = asyncio.get_event_loop()
    stl = AsyncSatel(host,
                     port,
                     loop,
                     [1, 2, 3, 4, 5, 6, 7, 8, 12, 13, 14, 15, 16, 17, 18, 19,
                      20, 21, 22, 23, 25, 26, 27, 28, 29, 30],
                     [8, 9, 10]
                     )

    loop.run_until_complete(stl.connect())
    loop.create_task(stl.arm("3333", 1))
    loop.create_task(stl.disarm("3333"))
    loop.create_task(stl.keep_alive())
    loop.create_task(stl.monitor_status())

    loop.run_forever()
    loop.close()


#
# Copyright 2014 Quantopian, Inc.
#
# Licensed 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.
"""
PostgreSQL implementation of IPython/Jupyter ContentsManager API.
"""
from __future__ import unicode_literals
from itertools import chain
from tornado import web

from .api_utils import (
    base_model,
    base_directory_model,
    from_b64,
    outside_root_to_404,
    reads_base64,
    to_api_path,
    to_b64,
    writes_base64,
)
from .checkpoints import PostgresCheckpoints
from .error import (
    CorruptedFile,
    DirectoryExists,
    DirectoryNotEmpty,
    FileExists,
    FileTooLarge,
    NoSuchDirectory,
    NoSuchFile,
    PathOutsideRoot,
    RenameRoot,
)
from .managerbase import PostgresManagerMixin
from .query import (
    delete_directory,
    delete_file,
    dir_exists,
    ensure_directory,
    file_exists,
    get_directory,
    get_file,
    get_file_id,
    purge_user,
    rename_directory,
    rename_file,
    save_file,
)
from .utils.ipycompat import Bool, ContentsManager, from_dict
from traitlets import default

class PostgresContentsManager(PostgresManagerMixin, ContentsManager):
    """
    ContentsManager that persists to a postgres database rather than to the
    local filesystem.
    """
    create_directory_on_startup = Bool(
        config=True,
        help="Create a root directory automatically?",
    )

    @default('checkpoints_class')
    def _default_checkpoints_class(self):
        return PostgresCheckpoints

    @default('checkpoints_kwargs')
    def _default_checkpoints_kwargs(self):
        klass = PostgresContentsManager
        try:
            kw = super(klass, self)._checkpoints_kwargs_default()
        except AttributeError:
            kw = super(klass, self)._default_checkpoints_kwargs()

        kw.update({
            'create_user_on_startup': self.create_user_on_startup,
            'crypto': self.crypto,
            'db_url': self.db_url,
            'max_file_size_bytes': self.max_file_size_bytes,
            'user_id': self.user_id,
        })
        return kw

    @default('create_directory_on_startup')
    def _default_create_directory_on_startup(self):
        return self.create_user_on_startup

    def __init__(self, *args, **kwargs):
        super(PostgresContentsManager, self).__init__(*args, **kwargs)
        if self.create_directory_on_startup:
            self.ensure_root_directory()

    def ensure_root_directory(self):
        with self.engine.begin() as db:
            ensure_directory(db, self.user_id, '')

    def purge_db(self):
        """
        Clear all matching our user_id.
        """
        with self.engine.begin() as db:
            purge_user(db, self.user_id)

    @outside_root_to_404
    def guess_type(self, path, allow_directory=True):
        """
        Guess the type of a file.

        If allow_directory is False, don't consider the possibility that the
        file is a directory.
        """
        if path.endswith('.ipynb'):
            return 'notebook'
        elif allow_directory and self.dir_exists(path):
            return 'directory'
        else:
            return 'file'

    # Begin ContentsManager API.
    @outside_root_to_404
    def dir_exists(self, path):
        with self.engine.begin() as db:
            return dir_exists(db, self.user_id, path)

    def is_hidden(self, path):
        return False

    @outside_root_to_404
    def file_exists(self, path):
        with self.engine.begin() as db:
            return file_exists(db, self.user_id, path)

    @outside_root_to_404
    def get(self, path, content=True, type=None, format=None):
        if type is None:
            type = self.guess_type(path)
        try:
            fn = {
                'notebook': self._get_notebook,
                'directory': self._get_directory,
                'file': self._get_file,
            }[type]
        except KeyError:
            raise ValueError("Unknown type passed: '{}'".format(type))

        try:
            return fn(path=path, content=content, format=format)
        except CorruptedFile as e:
            self.log.error(
                u'Corrupted file encountered at path %r. %s',
                path, e, exc_info=True,
            )
            self.do_500("Unable to read stored content at path %r." % path)

    @outside_root_to_404
    def get_file_id(self, path):
        """
        Get the id of a file in the database.  This function is specific to
        this implementation of ContentsManager and is not in the base class.
        """
        with self.engine.begin() as db:
            try:
                file_id = get_file_id(db, self.user_id, path)
            except NoSuchFile:
                self.no_such_entity(path)

        return file_id

    def _get_notebook(self, path, content, format):
        """
        Get a notebook from the database.
        """
        with self.engine.begin() as db:
            try:
                record = get_file(
                    db,
                    self.user_id,
                    path,
                    content,
                    self.crypto.decrypt,
                )
            except NoSuchFile:
                self.no_such_entity(path)

        return self._notebook_model_from_db(record, content)

    def _notebook_model_from_db(self, record, content):
        """
        Build a notebook model from database record.
        """
        path = to_api_path(record['parent_name'] + record['name'])
        model = base_model(path)
        model['type'] = 'notebook'
        model['last_modified'] = model['created'] = record['created_at']
        if content:
            content = reads_base64(record['content'])
            self.mark_trusted_cells(content, path)
            model['content'] = content
            model['format'] = 'json'
            self.validate_notebook_model(model)
        return model

    def _get_directory(self, path, content, format):
        """
        Get a directory from the database.
        """
        with self.engine.begin() as db:
            try:
                record = get_directory(
                    db, self.user_id, path, content
                )
            except NoSuchDirectory:
                if self.file_exists(path):
                    # TODO: It's awkward/expensive to have to check this to
                    # return a 400 instead of 404. Consider just 404ing.
                    self.do_400("Wrong type: %s" % path)
                else:
                    self.no_such_entity(path)

        return self._directory_model_from_db(record, content)

    def _convert_file_records(self, file_records):
        """
        Apply _notebook_model_from_db or _file_model_from_db to each entry
        in file_records, depending on the result of `guess_type`.
        """
        for record in file_records:
            type_ = self.guess_type(record['name'], allow_directory=False)
            if type_ == 'notebook':
                yield self._notebook_model_from_db(record, False)
            elif type_ == 'file':
                yield self._file_model_from_db(record, False, None)
            else:
                self.do_500("Unknown file type %s" % type_)

    def _directory_model_from_db(self, record, content):
        """
        Build a directory model from database directory record.
        """
        model = base_directory_model(to_api_path(record['name']))
        if content:
            model['format'] = 'json'
            model['content'] = list(
                chain(
                    self._convert_file_records(record['files']),
                    (
                        self._directory_model_from_db(subdir, False)
                        for subdir in record['subdirs']
                    ),
                )
            )
        return model

    def _file_model_from_db(self, record, content, format):
        """
        Build a file model from database record.
        """
        # TODO: Most of this is shared with _notebook_model_from_db.
        path = to_api_path(record['parent_name'] + record['name'])
        model = base_model(path)
        model['type'] = 'file'
        model['last_modified'] = model['created'] = record['created_at']
        if content:
            bcontent = record['content']
            model['content'], model['format'], model['mimetype'] = from_b64(
                path,
                bcontent,
                format,
            )
        return model

    def _get_file(self, path, content, format):
        with self.engine.begin() as db:
            try:
                record = get_file(
                    db,
                    self.user_id,
                    path,
                    content,
                    self.crypto.decrypt,
                )
            except NoSuchFile:
                if self.dir_exists(path):
                    # TODO: It's awkward/expensive to have to check this to
                    # return a 400 instead of 404. Consider just 404ing.
                    self.do_400(u"Wrong type: %s" % path)
                else:
                    self.no_such_entity(path)
        return self._file_model_from_db(record, content, format)

    def _save_notebook(self, db, model, path):
        """
        Save a notebook.

        Returns a validation message.
        """
        nb_contents = from_dict(model['content'])
        self.check_and_sign(nb_contents, path)
        save_file(
            db,
            self.user_id,
            path,
            writes_base64(nb_contents),
            self.crypto.encrypt,
            self.max_file_size_bytes,
        )
        # It's awkward that this writes to the model instead of returning.
        self.validate_notebook_model(model)
        return model.get('message')

    def _save_file(self, db, model, path):
        """
        Save a non-notebook file.
        """
        save_file(
            db,
            self.user_id,
            path,
            to_b64(model['content'], model.get('format', None)),
            self.crypto.encrypt,
            self.max_file_size_bytes,
        )
        return None

    def _save_directory(self, db, path):
        """
        'Save' a directory.
        """
        ensure_directory(db, self.user_id, path)

    @outside_root_to_404
    def save(self, model, path):
        if 'type' not in model:
            raise web.HTTPError(400, u'No model type provided')
        if 'content' not in model and model['type'] != 'directory':
            raise web.HTTPError(400, u'No file content provided')

        path = path.strip('/')

        # Almost all of this is duplicated with FileContentsManager :(.
        self.log.debug("Saving %s", path)
        if model['type'] not in ('file', 'directory', 'notebook'):
            self.do_400("Unhandled contents type: %s" % model['type'])
        try:
            with self.engine.begin() as db:
                if model['type'] == 'notebook':
                    validation_message = self._save_notebook(db, model, path)
                elif model['type'] == 'file':
                    validation_message = self._save_file(db, model, path)
                else:
                    validation_message = self._save_directory(db, path)
        except (web.HTTPError, PathOutsideRoot):
            raise
        except FileTooLarge:
            self.file_too_large(path)
        except Exception as e:
            self.log.error(u'Error while saving file: %s %s',
                           path, e, exc_info=True)
            self.do_500(
                u'Unexpected error while saving file: %s %s' % (path, e)
            )

        # TODO: Consider not round-tripping to the database again here.
        model = self.get(path, type=model['type'], content=False)
        if validation_message is not None:
            model['message'] = validation_message
        return model

    @outside_root_to_404
    def rename_file(self, old_path, path):
        """
        Rename object from old_path to path.

        NOTE: This method is unfortunately named on the base class.  It
        actually moves a file or a directory.
        """
        with self.engine.begin() as db:
            try:
                if self.file_exists(old_path):
                    rename_file(db, self.user_id, old_path, path)
                elif self.dir_exists(old_path):
                    rename_directory(db, self.user_id, old_path, path)
                else:
                    self.no_such_entity(path)
            except (FileExists, DirectoryExists):
                self.already_exists(path)
            except RenameRoot as e:
                self.do_409(str(e))

    def _delete_non_directory(self, path):
        with self.engine.begin() as db:
            deleted_count = delete_file(db, self.user_id, path)
            if not deleted_count:
                self.no_such_entity(path)

    def _delete_directory(self, path):
        with self.engine.begin() as db:
            try:
                deleted_count = delete_directory(db, self.user_id, path)
            except DirectoryNotEmpty:
                self.not_empty(path)
            if not deleted_count:
                self.no_such_entity(path)

    @outside_root_to_404
    def delete_file(self, path):
        """
        Delete object corresponding to path.
        """
        if self.file_exists(path):
            self._delete_non_directory(path)
        elif self.dir_exists(path):
            self._delete_directory(path)
        else:
            self.no_such_entity(path)