# Copyright (c) 2020-2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
    TYPE_CHECKING,
    Optional,
    Iterable,
    Iterator,
    cast,
    Sequence,
    Any,
)
import sys
import struct
import math
from enum import IntEnum
from itertools import repeat
from ezdxf.lldxf import const
from ezdxf.tools.binarydata import bytes_to_hexstr, ByteStream, BitStream
from ezdxf import colors
from ezdxf.math import (
    Vec3,
    Vec2,
    Matrix44,
    Z_AXIS,
    ConstructionCircle,
    ConstructionArc,
    OCS,
    UCS,
    X_AXIS,
)
from ezdxf.entities import factory
import logging

if TYPE_CHECKING:
    from ezdxf.document import Drawing
    from ezdxf.lldxf.tags import Tags
    from ezdxf.lldxf.tagwriter import AbstractTagWriter
    from ezdxf.entities import (
        DXFGraphic,
        Polymesh,
        Polyface,
        Polyline,
        Hatch,
        LWPolyline,
    )

logger = logging.getLogger("ezdxf")

CHUNK_SIZE = 127


class ProxyGraphicError(Exception):
    pass


def load_proxy_graphic(
    tags: Tags, length_code: int = 160, data_code: int = 310
) -> Optional[bytes]:
    binary_data = [
        tag.value
        for tag in tags.pop_tags(codes=(length_code, data_code))
        if tag.code == data_code
    ]
    return b"".join(binary_data) if len(binary_data) else None


def export_proxy_graphic(
    data: bytes,
    tagwriter: AbstractTagWriter,
    length_code: int = 160,
    data_code: int = 310,
) -> None:
    # Do not export proxy graphic for DXF R12 files
    assert tagwriter.dxfversion > const.DXF12

    length = len(data)
    if length == 0:
        return

    tagwriter.write_tag2(length_code, length)
    index = 0
    while index < length:
        hex_str = bytes_to_hexstr(data[index : index + CHUNK_SIZE])
        tagwriter.write_tag2(data_code, hex_str)
        index += CHUNK_SIZE


def has_prim_traits(flags: int) -> bool:
    return bool(flags & 0xFFFF)


def prims_have_colors(flags: int) -> bool:
    return bool(flags & 0x0001)


def prims_have_layers(flags: int) -> bool:
    return bool(flags & 0x0002)


def prims_have_linetypes(flags: int) -> bool:
    return bool(flags & 0x0004)


def prims_have_markers(flags: int) -> bool:
    return bool(flags & 0x0020)


def prims_have_visibilities(flags: int) -> bool:
    return bool(flags & 0x0040)


def prims_have_normals(flags: int) -> bool:
    return bool(flags & 0x0080)


def prims_have_orientation(flags: int) -> bool:
    return bool(flags & 0x0400)


TRAIT_TESTER = {
    "colors": (prims_have_colors, "RL"),
    "layers": (prims_have_layers, "RL"),
    "linetypes": (prims_have_linetypes, "RL"),
    "markers": (prims_have_markers, "RL"),
    "visibilities": (prims_have_visibilities, "RL"),
    "normals": (prims_have_normals, "3RD"),
}


def read_prim_traits(
    bs: ByteStream, types: Sequence[str], prim_flags: int, count: int
) -> dict:
    def read_float_list():
        return [bs.read_long() for _ in range(count)]

    def read_vertices():
        return [Vec3(bs.read_vertex()) for _ in range(count)]

    data = dict()
    for t in types:
        test_trait, data_type = TRAIT_TESTER[t]
        if test_trait(prim_flags):
            if data_type == "3RD":
                data[t] = read_vertices()
            elif data_type == "RL":
                data[t] = read_float_list()
            else:
                raise TypeError(data_type)
    return data


def read_mesh_traits(
    bs: ByteStream, edge_count: int, face_count: int, vertex_count: int
):
    # Traits data format:
    # all entries are optional
    # traits: dict[str, dict]
    #     "edges": dict[str, list]
    #         "colors": list[int]
    #         "layers": list[int] as layer ids
    #         "linetypes": list[int] as linetype ids
    #         "markers": list[int]
    #         "visibilities": list[int]
    #     "faces": dict[str, list]
    #         "colors": list[int]
    #         "layers": list[int] as layer ids
    #         "markers": list[int]
    #         "normals": list[Vec3]
    #         "visibilities": list[int]
    #     "vertices": dict
    #         "normals": list[Vec3]
    #         "orientation": bool
    traits = dict()
    edge_flags = bs.read_long()
    if has_prim_traits(edge_flags):
        traits["edges"] = read_prim_traits(
            bs,
            ["colors", "layers", "linetypes", "markers", "visibilities"],
            edge_flags,
            edge_count,
        )
    face_flags = bs.read_long()
    if has_prim_traits(face_flags):
        traits["faces"] = read_prim_traits(
            bs,
            ["colors", "layers", "markers", "normals", "visibilities"],
            face_flags,
            face_count,
        )

    # Note: DXF entities PolyFaceMesh and Mesh do not support vertex normals!
    # disable useless reading process by vertex_count = 0
    if vertex_count > 0:
        vertex_flags = bs.read_long()
        if has_prim_traits(vertex_flags):
            vertices = dict()
            if prims_have_normals(vertex_flags):
                vertices["normals"] = [
                    Vec3(bs.read_vertex()) for _ in range(vertex_count)
                ]
            if prims_have_orientation(vertex_flags):
                vertices["orientation"] = bool(bs.read_long())  # type: ignore
            traits["vertices"] = vertices
    return traits


class ProxyGraphicTypes(IntEnum):
    EXTENTS = 1
    CIRCLE = 2
    CIRCLE_3P = 3
    CIRCULAR_ARC = 4
    CIRCULAR_ARC_3P = 5
    POLYLINE = 6
    POLYGON = 7
    MESH = 8
    SHELL = 9
    TEXT = 10
    TEXT2 = 11
    XLINE = 12
    RAY = 13
    ATTRIBUTE_COLOR = 14
    UNUSED_15 = 15
    ATTRIBUTE_LAYER = 16
    UNUSED_17 = 17
    ATTRIBUTE_LINETYPE = 18
    ATTRIBUTE_MARKER = 19
    ATTRIBUTE_FILL = 20
    UNUSED_21 = 21
    ATTRIBUTE_TRUE_COLOR = 22
    ATTRIBUTE_LINEWEIGHT = 23
    ATTRIBUTE_LTSCALE = 24
    ATTRIBUTE_THICKNESS = 25
    ATTRIBUTE_PLOT_STYLE_NAME = 26
    PUSH_CLIP = 27
    POP_CLIP = 28
    PUSH_MATRIX = 29
    PUSH_MATRIX2 = 30
    POP_MATRIX = 31
    POLYLINE_WITH_NORMALS = 32
    LWPOLYLINE = 33
    ATTRIBUTE_MATERIAL = 34
    ATTRIBUTE_MAPPER = 35
    UNICODE_TEXT = 36
    UNKNOWN_37 = 37
    UNICODE_TEXT2 = 38
    ELLIPTIC_ARC = 44  # found in test data of issue #832


class ProxyGraphic:
    def __init__(
        self, data: bytes, doc: Optional[Drawing] = None, *, dxfversion=const.DXF2000
    ):
        self._doc = doc
        self._factory = factory.new
        self._buffer: bytes = data
        self._index: int = 8
        self.dxfversion = doc.dxfversion if doc else dxfversion
        self.encoding: str = "cp1252" if self.dxfversion < const.DXF2007 else "utf-8"
        self.color: int = const.BYLAYER
        self.layer: str = "0"
        self.linetype: str = "BYLAYER"
        self.marker_index: int = 0
        self.fill: bool = False
        self.true_color: Optional[int] = None
        self.lineweight: int = const.LINEWEIGHT_DEFAULT
        self.ltscale: float = 1.0
        self.thickness: float = 0.0
        # Layer list in storage order
        self.layers: list[str] = []
        # Linetypes list in storage order
        self.linetypes: list[str] = []
        # List of text styles, with font name as key
        self.textstyles: dict[str, str] = dict()
        self.required_fonts: set[str] = set()
        self.matrices: list[Matrix44] = []

        if self._doc:
            self.layers = list(layer.dxf.name for layer in self._doc.layers)
            self.linetypes = list(linetype.dxf.name for linetype in self._doc.linetypes)
            self.textstyles = {
                style.dxf.font: style.dxf.name for style in self._doc.styles
            }
            self.encoding = self._doc.encoding

    def info(self) -> Iterable[tuple[int, int, str]]:
        index = self._index
        buffer = self._buffer
        while index < len(buffer):
            size, type_ = struct.unpack_from("<2L", self._buffer, offset=index)
            try:
                name = ProxyGraphicTypes(type_).name
            except ValueError:
                name = f"UNKNOWN_TYPE_{type_}"
            yield index, size, name
            index += size

    def virtual_entities(self) -> Iterator[DXFGraphic]:
        return self.__virtual_entities__()

    def __virtual_entities__(self) -> Iterator[DXFGraphic]:
        """Implements the SupportsVirtualEntities protocol."""
        try:
            yield from self.unsafe_virtual_entities()
        except Exception as e:
            raise ProxyGraphicError(f"Proxy graphic error: {str(e)}")

    def unsafe_virtual_entities(self) -> Iterable[DXFGraphic]:
        def transform(entity):
            if self.matrices:
                return entity.transform(self.matrices[-1])
            else:
                return entity

        index = self._index
        buffer = self._buffer
        while index < len(buffer):
            size, type_ = struct.unpack_from("<2L", self._buffer, offset=index)
            try:
                name = ProxyGraphicTypes(type_).name.lower()
            except ValueError:
                logger.debug(f"Unsupported Type Code: {type_}")
                index += size
                continue
            method = getattr(self, name, None)
            if method:
                result = method(self._buffer[index + 8 : index + size])
                if isinstance(result, tuple):
                    for entity in result:
                        yield transform(entity)
                elif result:
                    yield transform(result)
                if result:  # reset fill after each graphic entity
                    self.fill = False
            else:
                logger.debug(f"Unsupported feature ProxyGraphic.{name}()")
            index += size

    def push_matrix(self, data: bytes):
        values = struct.unpack("<16d", data)
        m = Matrix44(values)
        m.transpose()
        self.matrices.append(m)

    def pop_matrix(self, data: bytes):
        if self.matrices:
            self.matrices.pop()

    def reset_colors(self):
        self.color = const.BYLAYER
        self.true_color = None

    def attribute_color(self, data: bytes):
        self.reset_colors()
        self.color = struct.unpack("<L", data)[0]
        if self.color < 0 or self.color > 256:
            self.color = const.BYLAYER

    def attribute_layer(self, data: bytes):
        if self._doc:
            index = struct.unpack("<L", data)[0]
            if index < len(self.layers):
                self.layer = self.layers[index]

    def attribute_linetype(self, data: bytes):
        if self._doc:
            index = struct.unpack("<L", data)[0]
            try:
                # first two entries ByLayer and ByBlock are not included in CAD applications:
                self.linetype = self.linetypes[index + 2]
            except IndexError:
                if index == 32766:
                    self.linetype = "BYBLOCK"
                else:  # index is 32767 or invalid
                    self.linetype = "BYLAYER"

    def attribute_marker(self, data: bytes):
        self.marker_index = struct.unpack("<L", data)[0]

    def attribute_fill(self, data: bytes):
        self.fill = bool(struct.unpack("<L", data)[0])

    def attribute_true_color(self, data: bytes):
        self.reset_colors()
        code, value = colors.decode_raw_color(struct.unpack("<L", data)[0])
        if code == colors.COLOR_TYPE_RGB:
            self.true_color = colors.rgb2int(value)  # type: ignore
        else:  # ACI colors, BYLAYER, BYBLOCK
            self.color = value  # type: ignore

    def attribute_lineweight(self, data: bytes):
        lw = struct.unpack("<L", data)[0]
        if lw > const.MAX_VALID_LINEWEIGHT:
            self.lineweight = max(lw - 0x100000000, const.LINEWEIGHT_DEFAULT)
        else:
            self.lineweight = lw

    def attribute_ltscale(self, data: bytes):
        self.ltscale = struct.unpack("<d", data)[0]

    def attribute_thickness(self, data: bytes):
        self.thickness = struct.unpack("<d", data)[0]

    def circle(self, data: bytes):
        bs = ByteStream(data)
        attribs = self._build_dxf_attribs()
        center = Vec3(bs.read_vertex())
        attribs["radius"] = bs.read_float()
        normal = Vec3(bs.read_vertex())
        attribs["extrusion"] = normal
        if not normal.isclose(Z_AXIS):
            # TODO: (issue 873) circle has a normal vector but center is in WCS
            #  It's not clear if all coordinates in proxy graphics are WCS coordinates
            #  even for OCS entities - the ODA DWG documentation contains no information
            #  about that.
            ocs = OCS(normal)
            center = ocs.from_wcs(center)
        attribs["center"] = center
        return self._factory("CIRCLE", dxfattribs=attribs)

    def circle_3p(self, data: bytes):
        bs = ByteStream(data)
        attribs = self._build_dxf_attribs()
        p1 = Vec3(bs.read_vertex())
        p2 = Vec3(bs.read_vertex())
        p3 = Vec3(bs.read_vertex())
        circle = ConstructionCircle.from_3p(p1, p2, p3)
        attribs["center"] = circle.center
        attribs["radius"] = circle.radius
        return self._factory("CIRCLE", dxfattribs=attribs)

    def circular_arc(self, data: bytes):
        bs = ByteStream(data)
        attribs = self._build_dxf_attribs()
        center = Vec3(bs.read_vertex())  # in WCS
        attribs["radius"] = bs.read_float()
        normal = Vec3(bs.read_vertex())  # UCS z-axis
        start_vec = Vec3(bs.read_vertex())  # UCS x-axis
        # sweep angle around normal vector!
        sweep_angle = bs.read_float()  # in radians
        # arc_type = bs.read_long()  # unused yet - meaning?
        start_angle: float  # in degrees
        end_angle: float  # in degrees
        if not normal.isclose(Z_AXIS):
            # local UCS
            ucs = UCS(ux=start_vec, uz=normal)
            # target OCS
            ocs = OCS(normal)
            # convert start angle == UCS x-axis to OCS
            start_angle = ocs.from_wcs(ucs.to_wcs(X_AXIS)).angle_deg
            # convert end angle to OCS
            end_vec = Vec3.from_angle(sweep_angle)
            end_angle = ocs.from_wcs(ucs.to_wcs(end_vec)).angle_deg
            # setup OCS for ARC entity
            attribs["extrusion"] = normal
            # convert WCS center to OCS center
            center = ocs.from_wcs(center)
        else:
            start_angle = start_vec.angle_deg
            end_angle = start_angle + math.degrees(sweep_angle)
        attribs["center"] = center
        attribs["start_angle"] = start_angle
        attribs["end_angle"] = end_angle
        return self._factory("ARC", dxfattribs=attribs)

    def circular_arc_3p(self, data: bytes):
        bs = ByteStream(data)
        attribs = self._build_dxf_attribs()
        p1 = Vec3(bs.read_vertex())
        p2 = Vec3(bs.read_vertex())
        p3 = Vec3(bs.read_vertex())
        # arc_type = bs.read_long()  # unused yet
        arc = ConstructionArc.from_3p(p1, p3, p2)
        attribs["center"] = arc.center
        attribs["radius"] = arc.radius
        attribs["start_angle"] = arc.start_angle
        attribs["end_angle"] = arc.end_angle
        return self._factory("ARC", dxfattribs=attribs)

    def elliptic_arc(self, data: bytes):
        bs = ByteStream(data)
        attribs = self._build_dxf_attribs()
        attribs["center"] = Vec3(bs.read_vertex())
        extrusion = Vec3(bs.read_vertex())
        attribs["extrusion"] = extrusion
        major_axis_length = bs.read_float()
        minor_axis_length = bs.read_float()
        attribs["ratio"] = minor_axis_length / major_axis_length
        start_param = bs.read_float()
        end_param = bs.read_float()
        major_axis_angle = bs.read_float()

        ocs = OCS(extrusion)
        major_axis = ocs.to_wcs(Vec3.from_angle(major_axis_angle, major_axis_length))
        attribs["major_axis"] = major_axis
        attribs["start_param"] = start_param
        attribs["end_param"] = end_param
        return self._factory("ELLIPSE", dxfattribs=attribs)

    def _filled_polygon(self, vertices, attribs):
        hatch = cast("Hatch", self._factory("HATCH", dxfattribs=attribs))
        elevation = _get_elevation(vertices)
        hatch.paths.add_polyline_path(Vec2.generate(vertices), is_closed=True)
        if elevation:
            hatch.dxf.elevation = Vec3(0, 0, elevation)
        return hatch

    def _polyline(self, vertices: list[Vec3], *, close=False, normal=Z_AXIS):
        # Polyline without bulge values!
        # Current implementation ignores the normal vector!
        # Polyline ignores the filled flag, see #906
        attribs = self._build_dxf_attribs()
        count = len(vertices)
        if count == 1 or (count == 2 and vertices[0].isclose(vertices[1])):
            attribs["location"] = vertices[0]
            return self._factory("POINT", dxfattribs=attribs)
        if not is_2d_polyline(vertices):
            attribs["flags"] = const.POLYLINE_3D_POLYLINE
        polyline = cast("Polyline", self._factory("POLYLINE", dxfattribs=attribs))
        polyline.append_vertices(vertices)
        if close:
            polyline.close()
        polyline.new_seqend()
        return polyline

    def polyline_with_normals(self, data: bytes):
        # Polyline without bulge values!
        # Polyline ignores the filled flag, see #906
        vertices, normal = self._load_vertices(data, load_normal=True)
        return self._polyline(vertices, normal=normal)

    def polyline(self, data: bytes):
        # Polyline without bulge values!
        # Polyline ignores the filled flag, see #906
        vertices, normal = self._load_vertices(data, load_normal=False)
        return self._polyline(vertices)

    def polygon(self, data: bytes):
        # Polyline without bulge values!
        vertices, normal = self._load_vertices(data, load_normal=False)
        if self.fill:
            return self._filled_polygon(vertices, self._build_dxf_attribs())
        return self._polyline(vertices, close=True)

    def lwpolyline(self, data: bytes):
        # OpenDesign Specs LWPLINE: 20.4.85 Page 211
        attribs = self._build_dxf_attribs()
        num_bulges = 0
        num_vertex_ids = 0
        num_width = 0
        is_closed = False
        bs = BitStream(data)

        num_data_bytes: int = bs.read_unsigned_long()
        flag: int = bs.read_bit_short()
        if flag & 4:
            attribs["const_width"] = bs.read_bit_double()
        if flag & 8:
            attribs["elevation"] = bs.read_bit_double()
        if flag & 2:
            attribs["thickness"] = bs.read_bit_double()
        if flag & 1:
            attribs["extrusion"] = Vec3(bs.read_bit_double(3))
        if flag & 512:  # todo: is this correct? not documented by the ODA DWG ref.
            is_closed = True
        num_points = bs.read_bit_long()
        if num_points <= 0:
            return None  # ignored in method unsafe_virtual_entities()

        if flag & 16:
            num_bulges = bs.read_bit_long()

        if self.dxfversion >= "AC1024":  # R2010+
            if flag & 1024:
                num_vertex_ids = bs.read_bit_long()
            if flag & 32:
                num_width = bs.read_bit_long()
        # ignore DXF R13/14 special vertex order

        vertices: list[tuple[float, float]] = [bs.read_raw_double(2)]  # type: ignore
        prev_point = vertices[-1]
        for _ in range(num_points - 1):
            x = bs.read_bit_double_default(default=prev_point[0])  # type: ignore
            y = bs.read_bit_double_default(default=prev_point[1])  # type: ignore
            prev_point = (x, y)
            vertices.append(prev_point)
        bulges: list[float] = [bs.read_bit_double() for _ in range(num_bulges)]
        vertex_ids: list[int] = [bs.read_bit_long() for _ in range(num_vertex_ids)]
        widths: list[tuple[float, float]] = [
            (bs.read_bit_double(), bs.read_bit_double()) for _ in range(num_width)
        ]
        if len(bulges) == 0:
            bulges = list(repeat(0, num_points))
        if len(widths) == 0:
            widths = list(repeat((0, 0), num_points))
        points: list[Sequence[float]] = []
        for v, w, b in zip(vertices, widths, bulges):
            points.append((v[0], v[1], w[0], w[1], b))
        lwpolyline = cast("LWPolyline", self._factory("LWPOLYLINE", dxfattribs=attribs))
        lwpolyline.set_points(points)
        lwpolyline.closed = is_closed
        return lwpolyline

    def mesh(self, data: bytes):
        # Limitations of the PolyFacMesh entity:
        # - all VERTEX entities have to reside on the same layer
        # - does not support vertex normals
        # - all faces have the same color (no face record)

        logger.warning("Untested proxy graphic entity: MESH - Need examples!")
        bs = ByteStream(data)
        rows, columns = bs.read_struct("<2L")
        total_edge_count = (rows - 1) * columns + (columns - 1) * rows
        total_face_count = (rows - 1) * (columns - 1)
        total_vertex_count = rows * columns
        vertices = [Vec3(bs.read_vertex()) for _ in range(total_vertex_count)]

        traits = dict()
        try:
            traits = read_mesh_traits(
                bs, total_edge_count, total_face_count, vertex_count=0
            )
        except struct.error:
            logger.error("Structure error while parsing traits for MESH proxy graphic")
        if traits:
            # apply traits
            pass

        # create PolyMesh entity
        attribs = self._build_dxf_attribs()
        attribs["m_count"] = rows
        attribs["n_count"] = columns
        attribs["flags"] = const.POLYLINE_3D_POLYMESH
        polymesh = cast("Polymesh", self._factory("POLYLINE", dxfattribs=attribs))
        polymesh.append_vertices(vertices)
        return polymesh

    def shell(self, data: bytes):
        # Limitations of the PolyFacMesh entity:
        # - all VERTEX entities have to reside on the same layer
        # - does not support vertex normals
        bs = ByteStream(data)
        attribs = self._build_dxf_attribs()
        attribs["flags"] = const.POLYLINE_POLYFACE
        polyface = cast("Polyface", self._factory("POLYLINE", dxfattribs=attribs))
        total_vertex_count = bs.read_long()
        vertices = [Vec3(bs.read_vertex()) for _ in range(total_vertex_count)]
        face_entry_count = bs.read_long()
        faces = []
        read_count: int = 0
        total_face_count: int = 0
        total_edge_count: int = 0
        while read_count < face_entry_count:
            edge_count = abs(bs.read_signed_long())
            read_count += 1 + edge_count
            face_indices = [bs.read_long() for _ in range(edge_count)]
            face = [vertices[index] for index in face_indices]
            total_face_count += 1
            total_edge_count += edge_count
            faces.append(face)

        traits = dict()
        try:
            traits = read_mesh_traits(
                bs, total_edge_count, total_face_count, vertex_count=0
            )
        except struct.error:
            logger.error("Structure error while parsing traits for SHELL proxy graphic")
        polyface.append_faces(faces)
        if traits:
            face_traits = traits.get("faces")
            if face_traits:
                face_colors = face_traits.get("colors")
                if face_colors:
                    logger.warning(
                        "Untested proxy graphic feature for SHELL: "
                        "apply face colors - Need examples!"
                    )
                    assert isinstance(face_colors, list)
                    _apply_face_colors(polyface, face_colors)
        polyface.optimize()
        return polyface

    def text(self, data: bytes):
        return self._text(data, unicode=False)

    def unicode_text(self, data: bytes):
        return self._text(data, unicode=True)

    def _text(self, data: bytes, unicode: bool = False):
        bs = ByteStream(data)
        start_point = Vec3(bs.read_vertex())
        normal = Vec3(bs.read_vertex())
        text_direction = Vec3(bs.read_vertex())
        height, width_factor, oblique_angle = bs.read_struct("<3d")
        text = ""
        if unicode:
            try:
                text = bs.read_padded_unicode_string()
            except UnicodeDecodeError as e:
                logger.debug(f"ProxyGraphic._text(unicode=True); {str(e)}")
        else:
            try:
                text = bs.read_padded_string(self.encoding)
            except UnicodeDecodeError as e:
                logger.debug(
                    f"ProxyGraphic._text(unicode=False); encoding={self.encoding}; {str(e)}"
                )
        attribs = self._build_dxf_attribs()
        attribs["insert"] = start_point
        attribs["text"] = text
        attribs["height"] = height
        attribs["width"] = width_factor
        attribs["rotation"] = text_direction.angle_deg
        attribs["oblique"] = math.degrees(oblique_angle)
        attribs["extrusion"] = normal
        return self._factory("TEXT", dxfattribs=attribs)

    def text2(self, data: bytes):
        encoding = self.encoding
        bs = ByteStream(data)
        start_point = Vec3(bs.read_vertex())
        normal = Vec3(bs.read_vertex())
        text_direction = Vec3(bs.read_vertex())
        text = ""
        try:
            text = bs.read_padded_string(encoding=encoding)
        except UnicodeDecodeError as e:
            logger.debug(f"ProxyGraphic.text2(); text; encoding={encoding}; {str(e)}")

        ignore_length_of_string, raw = bs.read_struct("<2l")
        (
            height,
            width_factor,
            oblique_angle,
            tracking_percentage,
        ) = bs.read_struct("<4d")
        (
            is_backwards,
            is_upside_down,
            is_vertical,
            is_underline,
            is_overline,
        ) = bs.read_struct("<5L")
        font_filename: str = "TXT.SHX"
        big_font_filename: str = ""
        try:
            font_filename = bs.read_padded_string(encoding=encoding)
            big_font_filename = bs.read_padded_string(encoding=encoding)
        except UnicodeDecodeError as e:
            logger.debug(
                f"ProxyGraphic.text2(); fonts; encoding='{encoding}'; {str(e)}"
            )

        attribs = self._build_dxf_attribs()
        attribs["insert"] = start_point
        attribs["text"] = text
        attribs["height"] = height
        attribs["width"] = width_factor
        attribs["rotation"] = text_direction.angle_deg
        attribs["oblique"] = math.degrees(oblique_angle)
        attribs["style"] = self._get_style(font_filename, big_font_filename)
        attribs["text_generation_flag"] = 2 * is_backwards + 4 * is_upside_down
        attribs["extrusion"] = normal
        return self._factory("TEXT", dxfattribs=attribs)

    def unicode_text2(self, data: bytes):
        bs = ByteStream(data)
        start_point = Vec3(bs.read_vertex())
        normal = Vec3(bs.read_vertex())
        text_direction = Vec3(bs.read_vertex())
        text = ""
        try:
            text = bs.read_padded_unicode_string()
        except UnicodeDecodeError as e:
            logger.debug(f"ProxyGraphic.unicode_text2(); text; {str(e)}")

        ignore_length_of_string, ignore_raw = bs.read_struct("<2l")
        (
            height,
            width_factor,
            oblique_angle,
            tracking_percentage,
        ) = bs.read_struct("<4d")
        (
            is_backwards,
            is_upside_down,
            is_vertical,
            is_underline,
            is_overline,
        ) = bs.read_struct("<5L")
        is_bold, is_italic, charset, pitch = bs.read_struct("<4L")

        type_face: str = ""
        font_filename: str = "TXT.SHX"
        big_font_filename: str = ""
        try:
            type_face = bs.read_padded_unicode_string()
            font_filename = bs.read_padded_unicode_string()
            big_font_filename = bs.read_padded_unicode_string()
        except UnicodeDecodeError as e:
            logger.debug(f"ProxyGraphic.unicode_text2(); fonts; {str(e)}")

        attribs = self._build_dxf_attribs()
        attribs["insert"] = start_point
        attribs["text"] = text
        attribs["height"] = height
        attribs["width"] = width_factor
        attribs["rotation"] = text_direction.angle_deg
        attribs["oblique"] = math.degrees(oblique_angle)
        attribs["style"] = self._get_style(font_filename, big_font_filename)
        attribs["text_generation_flag"] = 2 * is_backwards + 4 * is_upside_down
        attribs["extrusion"] = normal
        return self._factory("TEXT", dxfattribs=attribs)

    def xline(self, data: bytes):
        return self._xline(data, "XLINE")

    def ray(self, data: bytes):
        return self._xline(data, "RAY")

    def _xline(self, data: bytes, type_: str):
        logger.warning("Untested proxy graphic entity: RAY/XLINE - Need examples!")
        bs = ByteStream(data)
        attribs = self._build_dxf_attribs()
        start_point = Vec3(bs.read_vertex())
        other_point = Vec3(bs.read_vertex())
        attribs["start"] = start_point
        attribs["unit_vector"] = (other_point - start_point).normalize()
        return self._factory(type_, dxfattribs=attribs)

    def _get_style(self, font: str, bigfont: str) -> str:
        self.required_fonts.add(font)
        if font in self.textstyles:
            style = self.textstyles[font]
        else:
            style = font
            if self._doc and not self._doc.styles.has_entry(style):
                self._doc.styles.new(
                    font, dxfattribs={"font": font, "bigfont": bigfont}
                )
                self.textstyles[font] = style
        return style

    @staticmethod
    def _load_vertices(data: bytes, load_normal=False) -> tuple[list[Vec3], Vec3]:
        normal = Z_AXIS
        bs = ByteStream(data)
        count = bs.read_long()
        if load_normal:
            count += 1
        vertices: list[Vec3] = []
        while count > 0:
            vertices.append(Vec3(bs.read_struct("<3d")))
            count -= 1
        if load_normal:
            normal = vertices.pop()
        return vertices, normal

    def _build_dxf_attribs(self) -> dict[str, Any]:
        attribs: dict[str, Any] = dict()
        if self.layer != "0":
            attribs["layer"] = self.layer
        if self.color != const.BYLAYER:
            attribs["color"] = self.color
        if self.linetype != "BYLAYER":
            attribs["linetype"] = self.linetype
        if self.lineweight != const.LINEWEIGHT_DEFAULT:
            attribs["lineweight"] = self.lineweight
        if self.ltscale != 1.0:
            attribs["ltscale"] = self.ltscale
        if self.true_color is not None:
            attribs["true_color"] = self.true_color
        return attribs


class ProxyGraphicDebugger(ProxyGraphic):
    def __init__(self, data: bytes, doc: Optional[Drawing] = None, debug_stream=None):
        super(ProxyGraphicDebugger, self).__init__(data, doc)
        if debug_stream is None:
            debug_stream = sys.stdout
        self._debug_stream = debug_stream

    def log_entities(self):
        self.log_separator(char="=", newline=False)
        self.log_message("Create virtual DXF entities:")
        self.log_separator(newline=False)
        for entity in self.virtual_entities():
            self.log_message(f"\n  * {entity.dxftype()}")
            self.log_message(f"  * {entity.graphic_properties()}\n")
        self.log_separator(char="=")

    def log_commands(self):
        self.log_separator(char="=", newline=False)
        self.log_message("Raw proxy commands:")
        self.log_separator(newline=False)
        for index, size, cmd in self.info():
            self.log_message(f"Command: {cmd} Index: {index} Size: {size}")
        self.log_separator(char="=")

    def log_separator(self, char="-", newline=True):
        self.log_message(char * 79)
        if newline:
            self.log_message("")

    def log_message(self, msg: str):
        print(msg, file=self._debug_stream)

    def log_state(self):
        self.log_message("> " + self.get_state())

    def get_state(self) -> str:
        return (
            f"ly: '{self.layer}', clr: {self.color}, lt: {self.linetype}, "
            f"lw: {self.lineweight}, ltscale: {self.ltscale}, "
            f"rgb: {self.true_color}, fill: {self.fill}"
        )

    def attribute_color(self, data: bytes):
        self.log_message("Command: set COLOR")
        super().attribute_color(data)
        self.log_state()

    def attribute_layer(self, data: bytes):
        self.log_message("Command: set LAYER")
        super().attribute_layer(data)
        self.log_state()

    def attribute_linetype(self, data: bytes):
        self.log_message("Command: set LINETYPE")
        super().attribute_linetype(data)
        self.log_state()

    def attribute_true_color(self, data: bytes):
        self.log_message("Command: set TRUE-COLOR")
        super().attribute_true_color(data)
        self.log_state()

    def attribute_lineweight(self, data: bytes):
        self.log_message("Command: set LINEWEIGHT")
        super().attribute_lineweight(data)
        self.log_state()

    def attribute_ltscale(self, data: bytes):
        self.log_message("Command: set LTSCALE")
        super().attribute_ltscale(data)
        self.log_state()

    def attribute_fill(self, data: bytes):
        self.log_message("Command: set FILL")
        super().attribute_fill(data)
        self.log_state()


def _apply_face_colors(polyface: Polyface, colors: list[int]) -> None:
    color_count: int = len(colors)
    if color_count == 0:
        return

    index: int = 0
    for vertex in polyface.vertices:
        if vertex.is_face_record:
            vertex.dxf.color = colors[index]
            index += 1
            if index >= color_count:
                return


def _get_elevation(vertices) -> float:
    if vertices:
        return vertices[0].z
    return 0.0


def is_2d_polyline(vertices: list[Vec3]) -> bool:
    if len(vertices) < 1:
        return True
    z = vertices[0].z
    return all(math.isclose(z, v.z) for v in vertices)
