from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

import struct

import compas
from compas import _iotools


class PLY(object):
    """Class for working with files in Polygon format, also known as Stanford triangle format.

    Parameters
    ----------
    filepath : path string | file-like object | URL string
        A path, a file-like object or a URL pointing to a file.
    precision : str, optional
        A COMPAS precision specification.

    Attributes
    ----------
    reader : :class:`PLYReader`
        A PLY file reader.
    parser : :class:`PLYParser`
        A PLY data parser.

    References
    ----------
    * http://paulbourke.net/dataformats/ply/

    """

    def __init__(self, filepath, precision=None):
        self.filepath = filepath
        self.precision = precision
        self._is_parsed = False
        self._reader = None
        self._parser = None
        self._writer = None

    @property
    def reader(self):
        if not self._is_parsed:
            self.read()
        return self._reader

    @property
    def parser(self):
        if not self._is_parsed:
            self.read()
        return self._parser

    def read(self):
        """Read the contents of the file.

        Returns
        -------
        None

        """
        self._reader = PLYReader(self.filepath)
        self._parser = PLYParser(self._reader, precision=self.precision)
        self._is_parsed = True

    def write(self, mesh, **kwargs):
        """Write a mesh to the file.

        Parameters
        ----------
        mesh : :class:`~compas.datastructures.Mesh`
            The mesh.
        author : str, optional
            The author name to include in the header.
        email : str, optional
            The email of the author to include in the header.
        date : str, optional
            The date to include in the header.
        precision : str, optional
            COMPAS precision specification for parsing geometric data.

        Returns
        -------
        None

        """
        self._writer = PLYWriter(self.filepath, mesh, **kwargs)
        self._writer.write()


class PLYReader(object):
    """Class for reading raw geometric data from PLY files.

    Parameters
    ----------
    filepath : path string | file-like object | URL string
        A path, a file-like object or a URL pointing to a file.

    Class Attributes
    ----------------
    keywords : list[str]
        Reserved keywords in PLY format language.
    property_types : dict[str, object]
        Mapping between PLY property types and Python types.
    binary_property_types : dict[str, str]
        Mapping between PLY property types and binary type strings.
    number_of_bytes_per_type : dict[str, int]
        Mapping between PLY property types and number of bytes.
    struct_format_per_type : dict[str, str]
        Mapping between PLY property types and struct formats.
    binary_byte_order : dict[str, str]
        Mapping between endian type and endian symbol.

    """

    keywords = ["ply", "format", "comment", "element", "property", "end_header"]

    property_types = {
        "char": int,
        "uchar": int,
        "short": int,
        "ushort": int,
        "int": int,
        "int32": int,
        "int64": int,
        "uint": int,
        "uint32": int,
        "uint64": int,
        "float": float,
        "float32": float,
        "float64": float,
        "double": float,
    }

    binary_property_types = {
        "int8": "i1",
        "char": "i1",
        "uint8": "u1",
        "uchar": "u1",
        "int16": "i2",
        "short": "i2",
        "uint16": "u2",
        "ushort": "u2",
        "int32": "i4",
        "int": "i4",
        "uint32": "u4",
        "uint": "u4",
        "float32": "f4",
        "float": "f4",
        "float64": "f8",
        "double": "f8",
    }

    number_of_bytes_per_type = {
        "char": 1,
        "uchar": 1,
        "short": 2,
        "ushort": 2,
        "int": 4,
        "uint": 4,
        "float": 4,
        "double": 8,
    }

    struct_format_per_type = {
        "char": "c",
        "uchar": "B",
        "short": "h",
        "ushort": "H",
        "int": "i",
        "uint": "I",
        "float": "f",
        "double": "d",
    }

    binary_byte_order = {"binary_big_endian": ">", "binary_little_endian": "<"}

    def __init__(self, filepath):
        self.filepath = filepath
        self.file = None
        self.format = None
        self.comments = []
        self.header = []
        self.start_header = None
        self.end_header = None
        self.number_of_vertices = None
        self.number_of_edges = None
        self.number_of_faces = None
        self.vertex_properties = []
        self.edge_properties = []
        self.face_properties = []
        self.sections = []
        self.vertices = []
        self.edges = []
        self.faces = []
        self.read()

    def is_valid(self):
        """Verify that the file is valid by reading the header.

        Returns
        -------
        bool

        """
        self._read_header()
        if self.start_header and self.end_header:
            return True
        return False

    def is_binary(self):
        """Verify that the file is in binary format.

        Returns
        -------
        bool

        """
        if self.format == "binary_big_endian":
            return True
        if self.format == "binary_little_endian":
            return True
        return False

    def is_ascii(self):
        """Verify that the file is in ASCII format.

        Returns
        -------
        bool

        """
        if self.format == "ascii":
            return True
        return False

    def read(self):
        """Read the contents of the file.

        Returns
        -------
        None

        """
        self._read_header()
        if self.format == "ascii":
            self._read_data()
        else:
            self._read_data_binary()

    # ==========================================================================
    # read the header
    # ==========================================================================

    def _read_header(self):
        # the header is always in ascii format
        # read it as text
        # otherwise file.tell() can't be used reliably
        # to figure out where the header ends
        with _iotools.open_file(self.filepath) as file:
            file.seek(0)

            line = file.readline().rstrip()

            if line.lower() != "ply":
                raise Exception("not a valid ply file")

            self.start_header = file.tell()

            element_type = None

            while True:
                line = file.readline()
                line = line.rstrip()

                self.header.append(line)

                if line.startswith("format"):
                    element_type = None
                    self.format = line[len("format") + 1 :].split(" ")[0]

                elif line.startswith("comment"):
                    element_type = None
                    self.comments.append(line[len("comment") + 1 :])

                elif line.startswith("element"):
                    parts = line.split()
                    element_type = parts[1]
                    if element_type == "vertex":
                        self.sections.append("vertex")
                        self.number_of_vertices = int(parts[2])
                    elif element_type == "edge":
                        self.sections.append("edge")
                        self.number_of_edges = int(parts[2])
                    elif element_type == "face":
                        self.sections.append("face")
                        self.number_of_faces = int(parts[2])
                    else:
                        element_type = None
                        raise Exception

                elif line.startswith("property"):
                    parts = line.split()
                    if element_type == "vertex":
                        property_type = parts[1]
                        property_name = parts[2]
                        self.vertex_properties.append((property_name, property_type))
                    elif element_type == "edge":
                        property_type = parts[1]
                        property_name = parts[2]
                        self.edge_properties.append((property_name, property_type))
                    elif element_type == "face":
                        property_type = parts[1]
                        if property_type == "list":
                            property_length = parts[2]
                            property_type = parts[3]
                            property_name = parts[4]
                            self.face_properties.append((property_name, property_type, property_length))
                        else:
                            property_type = parts[1]
                            property_name = parts[2]
                            self.face_properties.append((property_name, property_type))
                    else:
                        element_type = None
                        raise Exception

                elif line == "end_header":
                    element_type = None
                    self.end_header = file.tell()
                    break

                else:
                    pass

    # ==========================================================================
    # read the data
    # ==========================================================================

    def _read_data(self):
        if not self.end_header:
            raise Exception("header has not been read, or the file is not valid")
        with _iotools.open_file(self.filepath) as self.file:
            self.file.seek(self.end_header)
            for section in self.sections:
                if section == "vertex":
                    self._read_vertices()
                elif section == "edge":
                    self._read_edges()
                elif section == "face":
                    self._read_faces()
                else:
                    print("user-defined elements are not supported: {0}".format(section))
                    pass

    def _read_data_binary(self):
        if not self.end_header:
            raise Exception("header has not been read, or the file is not valid")
        with _iotools.open_file(self.filepath, "rb") as self.file:
            self.file.seek(self.end_header)
            for section in self.sections:
                if section == "vertex":
                    self._read_vertices_binary_wo_numpy()
                elif section == "edge":
                    self._read_edges_binary_wo_numpy()
                elif section == "face":
                    self._read_faces_binary_wo_numpy()
                else:
                    print("user-defined elements are not supported: {0}".format(section))
                    pass

    # ==========================================================================
    # read the individual section
    # ==========================================================================

    def _read_vertices(self):
        n = len(self.vertex_properties)
        for _ in range(self.number_of_vertices):
            vertex = {}
            i = 0
            while i < n:
                line = next(self.file)
                parts = line.rstrip().split()
                for prop_str in parts:
                    prop_name, prop_type = self.vertex_properties[i]
                    vertex[prop_name] = self.property_types[prop_type](prop_str)
                    i += 1
            self.vertices.append(vertex)
        # count = 0
        # for line in self.file:
        #     line = line.rstrip()
        #     parts = line.split()
        #     vertex = {}
        #     for i, prop in enumerate(self.vertex_properties):
        #         pname, ptype = prop
        #         vertex[pname] = self.property_types[ptype](parts[i])
        #     self.vertices.append(vertex)
        #     count += 1
        #     if count == self.number_of_vertices:
        #         break

    def _read_edges(self):
        pass

    def _read_faces(self):
        count = 0
        for line in self.file:
            line = line.rstrip()
            parts = line.split()
            face = {}
            for i, prop in enumerate(self.face_properties):
                pname, ptype, plen = prop
                face[pname] = [self.property_types[ptype](part) for part in parts[1:]]
            self.faces.append(face)
            count += 1
            if count == self.number_of_faces:
                break

    # ==========================================================================
    # binary read the individual section
    # ==========================================================================

    # remove numpy dependency by reading the file in chuncks
    # with each chunck equal to the size specified in the header?
    # see: http://stackoverflow.com/questions/4566498/python-file-iterator-over-a-binary-file-with-newer-idiom
    # see: http://stackoverflow.com/questions/27532738/python-iterate-through-binary-file-without-lines

    def _numpy_vertex_ptypes(self):
        ext = self.binary_byte_order[self.format]
        dt = []
        for prop in self.vertex_properties:
            pname, ptype = prop
            dt.append((pname, ext + self.binary_property_types[ptype]))
        return dt

    def _numpy_face_ptypes(self):
        ext = self.binary_byte_order[self.format]
        dt = []
        for prop in self.face_properties:
            if len(prop) == 2:
                pname, ptype = prop
                dt.append((pname, ext + self.binary_property_types[ptype]))
            elif len(prop) == 3:
                pname, ptype, plen = prop
                dt.append(("size", ext + self.binary_property_types[plen]))
                # this seems a nit of a hack
                dt.append(("v1", ext + self.binary_property_types[ptype]))
                dt.append(("v2", ext + self.binary_property_types[ptype]))
                dt.append(("v3", ext + self.binary_property_types[ptype]))
            else:
                pass
        return dt

    def _read_vertices_binary_wo_numpy(self):
        ext = self.binary_byte_order[self.format]
        fmt = ext
        chunk = 0
        for prop in self.vertex_properties:
            pname, ptype = prop
            chunk += self.number_of_bytes_per_type[ptype]
            fmt += self.struct_format_per_type[ptype]
        for i in range(self.number_of_vertices):
            data = self.file.read(chunk)
            data = struct.unpack(fmt, data)
            vertex = {}
            for i, prop in enumerate(self.vertex_properties):
                pname, ptype = prop
                vertex[pname] = data[i]
            self.vertices.append(vertex)

    def _read_vertices_binary(self):
        # use pandas to read the data frames
        import numpy as np

        for line in np.fromfile(
            self.file,
            dtype=np.dtype(self._numpy_vertex_ptypes()),
            count=self.number_of_vertices,
        ):
            vertex = {}
            for i, prop in enumerate(self.vertex_properties):
                pname, ptype = prop
                vertex[pname] = line[i]
            self.vertices.append(vertex)

    def _read_edges_binary_wo_numpy(self):
        pass

    def _read_edges_binary(self):
        pass

    def _read_faces_binary_wo_numpy(self):
        ext = self.binary_byte_order[self.format]
        fmt = ext
        chunk = 0
        for prop in self.face_properties:
            if len(prop) == 2:
                pname, ptype = prop
                chunk += self.number_of_bytes_per_type[ptype]
                fmt += self.struct_format_per_type[ptype]
            elif len(prop) == 3:
                pname, ptype, plen = prop
                chunk += self.number_of_bytes_per_type[plen]
                chunk += self.number_of_bytes_per_type[ptype] * 3
                fmt += self.struct_format_per_type[plen]
                fmt += self.struct_format_per_type[ptype] * 3
            else:
                pass
        for i in range(self.number_of_faces):
            data = self.file.read(chunk)
            data = struct.unpack(fmt, data)
            face = {}
            for i, prop in enumerate(self.face_properties):
                if len(prop) == 2:
                    pname, ptype = prop
                    face[pname] = data[i]
                elif len(prop) == 3:
                    pname, ptype, plen = prop
                    face[pname] = list(data[2:])
            self.faces.append(face)

    def _read_faces_binary(self):
        # use pandas to read the data frames
        # how to deal with faces of variable length?
        import numpy as np

        for line in np.fromfile(
            self.file,
            dtype=np.dtype(self._numpy_face_ptypes()),
            count=self.number_of_faces,
        ):
            face = {}
            for i, prop in enumerate(self.face_properties):
                if len(prop) == 2:
                    pname, ptype = prop
                    face[pname] = line[i]
                elif len(prop) == 3:
                    pname, ptype, plen = prop
                    # type(line) => numpy.void
                    # convert the line to a list
                    line = list(line)
                    face[pname] = line[2:]
                else:
                    pass
            self.faces.append(face)


class PLYParser(object):
    """Class for parsing data from a OBJ file.

    The parser converts the raw geometric data of the file
    into corresponding COMPAS geometry objects and data structures.

    Parameters
    ----------
    reader : :class:`PLYReader`
        A PLY file reader.
    precision : str
        COMPAS precision specification for parsing geometric data.

    Attributes
    ----------
    vertices : list[tuple[float, float, float]]
        The vertex coordinates.
    edges : list[tuple[int, int]]
        Pairs of vertex indices defining the start and end points of edges.
    faces : list[list[int]]
        Lists of vertex indices defining faces.

    """

    def __init__(self, reader, precision=None):
        self.precision = precision
        self.reader = reader
        self.vertices = None
        self.edges = None
        self.faces = None
        self.parse()

    def parse(self):
        """Parse the contents found by a PLY file reader.

        Returns
        -------
        None

        """
        self.vertices = [(vertex["x"], vertex["y"], vertex["z"]) for vertex in self.reader.vertices]
        self.faces = [face["vertex_indices"] for face in self.reader.faces]


class PLYWriter(object):
    """Class for writing geometric data to a PLY file.

    Parameters
    ----------
    filepath : path string | file-like object | URL string
        A path, a file-like object or a URL pointing to a file.
    mesh : :class:`~compas.datastructures.Mesh`
        Mesh to write to the file.
    author : str, optional
        The author name to include in the header.
    email : str, optional
        The email of the author to include in the header.
    date : str, optional
        The date to include in the header.
    precision : str, optional
        COMPAS precision specification for parsing geometric data.

    """

    def __init__(self, filepath, mesh, author=None, email=None, date=None, precision=None):
        self.filepath = filepath
        self.mesh = mesh
        self.author = author
        self.email = email
        self.date = date
        self.precision = precision or compas.PRECISION
        self.vertex_tpl = (
            "{0:." + self.precision + "}" + " {1:." + self.precision + "}" + " {2:." + self.precision + "}\n"
        )
        self.v = mesh.number_of_vertices()
        self.f = mesh.number_of_faces()
        self.e = mesh.number_of_edges()
        self.file = None

    def write(self):
        """Write the data to a file.

        Returns
        -------
        None

        """
        with _iotools.open_file(self.filepath, "w") as self.file:
            self._write_header()
            self._write_vertices()
            self._write_faces()

    def _write_header(self):
        self.file.write("PLY\n")
        self.file.write("format ascii 1.0\n")
        if self.author:
            self.file.write("comment author: {}\n".format(self.author))
        if self.email:
            self.file.write("comment email: {}\n".format(self.email))
        if self.date:
            self.file.write("comment date: {}\n".format(self.date))
        self.file.write("element vertex {}\n".format(self.v))
        self.file.write("property float x\n")
        self.file.write("property float y\n")
        self.file.write("property float z\n")
        self.file.write("element face {}\n".format(self.f))
        self.file.write("property list uchar int vertex_indices\n")
        self.file.write("end_header\n")

    def _write_vertices(self):
        for key in self.mesh.vertices():
            x, y, z = self.mesh.vertex_coordinates(key)
            self.file.write(self.vertex_tpl.format(x, y, z))

    def _write_faces(self):
        vertex_index = self.mesh.vertex_index()
        for face in self.mesh.faces():
            vertices = self.mesh.face_vertices(face)
            v = len(vertices)
            self.file.write("{0} {1}\n".format(v, " ".join([str(vertex_index[vertex]) for vertex in vertices])))
