# Copyright (c) 2018-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
    TYPE_CHECKING,
    Iterable,
    Optional,
    Sequence,
    Iterator,
)
from collections import OrderedDict, namedtuple
import math

from ezdxf.audit import AuditError
from ezdxf.entities.factory import register_entity
from ezdxf.lldxf import const, validator
from ezdxf.lldxf.attributes import (
    DXFAttr,
    DXFAttributes,
    DefSubclass,
    XType,
    RETURN_DEFAULT,
    group_code_mapping,
)
from ezdxf.lldxf.tags import Tags, group_tags
from ezdxf.math import NULLVEC, X_AXIS, Y_AXIS, Z_AXIS, UVec, Vec3, UCS, OCS

from .dxfentity import base_class, SubclassProcessor
from .dxfobj import DXFObject
from .dxfgfx import DXFGraphic, acdb_entity
from .objectcollection import ObjectCollection

import logging

if TYPE_CHECKING:
    from ezdxf.audit import Auditor
    from ezdxf.document import Drawing
    from ezdxf.entities import DXFNamespace, DXFEntity
    from ezdxf.layouts import BaseLayout
    from ezdxf.lldxf.tagwriter import AbstractTagWriter
    from ezdxf.math import Matrix44
    from ezdxf.query import EntityQuery

__all__ = ["MLine", "MLineVertex", "MLineStyle", "MLineStyleCollection"]
logger = logging.getLogger("ezdxf")

# Usage example: CADKitSamples\Lock-Off.dxf


def filter_close_vertices(
    vertices: Iterable[Vec3], abs_tol: float = 1e-12
) -> Iterable[Vec3]:
    prev = None
    for vertex in vertices:
        if prev is None:
            yield vertex
            prev = vertex
        else:
            if not vertex.isclose(prev, abs_tol=abs_tol):
                yield vertex
                prev = vertex


acdb_mline = DefSubclass(
    "AcDbMline",
    OrderedDict(
        {
            "style_name": DXFAttr(2, default="Standard"),
            "style_handle": DXFAttr(340),
            "scale_factor": DXFAttr(
                40,
                default=1,
                validator=validator.is_not_zero,
                fixer=RETURN_DEFAULT,
            ),
            # Justification
            # 0 = Top (Right)
            # 1 = Zero (Center)
            # 2 = Bottom (Left)
            "justification": DXFAttr(
                70,
                default=0,
                validator=validator.is_in_integer_range(0, 3),
                fixer=RETURN_DEFAULT,
            ),
            # Flags (bit-coded values):
            # 1 = Has at least one vertex (code 72 is greater than 0)
            # 2 = Closed
            # 4 = Suppress start caps
            # 8 = Suppress end caps
            "flags": DXFAttr(71, default=1),
            # Number of MLINE vertices
            "count": DXFAttr(72, xtype=XType.callback, getter="__len__"),
            # Number of elements in MLINESTYLE definition
            "style_element_count": DXFAttr(73, default=2),
            # start location in WCS!
            "start_location": DXFAttr(
                10, xtype=XType.callback, getter="start_location"
            ),
            # Normal vector of the entity plane, but all vertices in WCS!
            "extrusion": DXFAttr(
                210,
                xtype=XType.point3d,
                default=Z_AXIS,
                validator=validator.is_not_null_vector,
                fixer=RETURN_DEFAULT,
            ),
            # MLine data:
            # 11: vertex coordinates
            #     Multiple entries; one entry for each vertex.
            # 12: Direction vector of segment starting at this vertex
            #     Multiple entries; one for each vertex.
            # 13: Direction vector of miter at this vertex
            #     Multiple entries: one for each vertex.
            # 74: Number of parameters for this element,
            #     repeats for each element in segment
            # 41: Element parameters,
            #     repeats based on previous code 74
            # 75: Number of area fill parameters for this element,
            #     repeats for each element in segment
            # 42: Area fill parameters,
            #     repeats based on previous code 75
        }
    ),
)
acdb_mline_group_codes = group_code_mapping(acdb_mline)


# For information about line- and fill parametrization see comments in class
# MLineVertex().
#
# The 2 group codes in mline entities and mlinestyle objects are redundant
# fields. These groups should not be modified under any circumstances, although
# it is safe to read them and use their values. The correct fields to modify
# are as follows:
#
# Mline
# The 340 group in the same object, which indicates the proper MLINESTYLE
# object.
#
# Mlinestyle
# The 3 group value in the MLINESTYLE dictionary, which precedes the 350 group
# that has the handle or entity name of
# the current mlinestyle.

# Facts and assumptions not clearly defined by the DXF reference:
# - the reference line is defined by the group code 11 points (fact)
# - all line segments are parallel to the reference line (assumption)
# - all line vertices are located in the same plane, the orientation of the plane
#   is defined by the extrusion vector (assumption)
# - the scale factor is applied to to all geometries
# - the start- and end angle (MLineStyle) is also applied to the first and last
#   miter direction vector
# - the last two points mean: all geometries and direction vectors can be used
#   as stored in the DXF file no additional scaling or rotation is necessary
#   for the MLINE rendering. Disadvantage: minor changes of DXF attributes
#   require a refresh of the MLineVertices.

# Ezdxf does not support the creation of line-break (gap) features, but will be
# preserving this data if the MLINE stays unchanged.
# Editing the MLINE entity by ezdxf removes the line-break features (gaps).


class MLineVertex:
    def __init__(self) -> None:
        self.location: Vec3 = NULLVEC
        self.line_direction: Vec3 = X_AXIS
        self.miter_direction: Vec3 = Y_AXIS

        # Line parametrization (74/41)
        # ----------------------------
        # The line parameterization is a list of float values.
        # The list may contain zero or more items.
        #
        # The first value (miter-offset) is the distance from the vertex
        # location along the miter direction vector to the point where the
        # line element's path intersects the miter vector.
        #
        # The next value (line-start-offset) is the distance along the line
        # direction from the miter/line path intersection point to the actual
        # start of the line element.
        #
        # The next value (dash-length) is the distance from the start of the
        # line element (dash) to the first break (or gap) in the line element.
        # The successive values continue to list the start and stop points of
        # the line element in this segment of the mline.
        # Linetypes do not affect the line parametrization.
        #
        #
        # 1. line element: [miter-offset, line-start-offset, dash, gap, dash, ...]
        # 2. line element: [...]
        # ...
        self.line_params: list[Sequence[float]] = []
        """ The line parameterization is a list of float values.
        The list may contain zero or more items.
        """

        # Fill parametrization (75/42)
        # ----------------------------
        #
        # The fill parameterization is also a list of float values.
        # Similar to the line parametrization, it describes the
        # parametrization of the fill area for this mline segment.
        # The values are interpreted identically to the line parameters and when
        # taken as a whole for all line elements in the mline segment, they
        # define the boundary of the fill area for the mline segment.
        #
        # A common example of the use of the fill mechanism is when an
        # unfilled mline crosses over a filled mline and "mledit" is used to
        # cause the filled mline to appear unfilled in the crossing area.
        # This would result in two fill parameters for each line element in the
        # affected mline segment; one for the fill stop and one for the fill
        # start.
        #
        # [dash-length, gap-length, ...]?
        self.fill_params: list[Sequence[float]] = []

    def __copy__(self) -> MLineVertex:
        vtx = self.__class__()
        vtx.location = self.location
        vtx.line_direction = self.line_direction
        vtx.miter_direction = self.miter_direction
        vtx.line_params = list(self.line_params)
        vtx.fill_params = list(self.fill_params)
        return vtx

    copy = __copy__

    @classmethod
    def load(cls, tags: Tags) -> MLineVertex:
        vtx = MLineVertex()
        line_params: list[float] = []
        line_params_count = 0
        fill_params: list[float] = []
        fill_params_count = 0
        for code, value in tags:
            if code == 11:
                vtx.location = Vec3(value)
            elif code == 12:
                vtx.line_direction = Vec3(value)
            elif code == 13:
                vtx.miter_direction = Vec3(value)
            elif code == 74:
                line_params_count = value
                if line_params_count == 0:
                    vtx.line_params.append(tuple())
                else:
                    line_params = []
            elif code == 41:
                line_params.append(value)
                line_params_count -= 1
                if line_params_count == 0:
                    vtx.line_params.append(tuple(line_params))
                    line_params = []
            elif code == 75:
                fill_params_count = value
                if fill_params_count == 0:
                    vtx.fill_params.append(tuple())
                else:
                    fill_params = []
            elif code == 42:
                fill_params.append(value)
                fill_params_count -= 1
                if fill_params_count == 0:
                    vtx.fill_params.append(tuple(fill_params))
        return vtx

    def export_dxf(self, tagwriter: AbstractTagWriter):
        tagwriter.write_vertex(11, self.location)
        tagwriter.write_vertex(12, self.line_direction)
        tagwriter.write_vertex(13, self.miter_direction)
        for line_params, fill_params in zip(self.line_params, self.fill_params):
            tagwriter.write_tag2(74, len(line_params))
            for param in line_params:
                tagwriter.write_tag2(41, param)
            tagwriter.write_tag2(75, len(fill_params))
            for param in fill_params:
                tagwriter.write_tag2(42, param)

    @classmethod
    def new(
        cls,
        start: UVec,
        line_direction: UVec,
        miter_direction: UVec,
        line_params: Optional[Iterable[Sequence[float]]] = None,
        fill_params: Optional[Iterable[Sequence[float]]] = None,
    ) -> MLineVertex:
        vtx = MLineVertex()
        vtx.location = Vec3(start)
        vtx.line_direction = Vec3(line_direction)
        vtx.miter_direction = Vec3(miter_direction)
        vtx.line_params = list(line_params or [])
        vtx.fill_params = list(fill_params or [])
        if len(vtx.line_params) != len(vtx.fill_params):
            raise const.DXFValueError(
                "Count mismatch of line- and fill parameters"
            )
        return vtx

    def transform(self, m: Matrix44) -> MLineVertex:
        """Transform MLineVertex by transformation matrix `m` inplace."""
        self.location = m.transform(self.location)
        self.line_direction = m.transform_direction(self.line_direction)
        self.miter_direction = m.transform_direction(self.miter_direction)
        return self


@register_entity
class MLine(DXFGraphic):
    DXFTYPE = "MLINE"
    DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_mline)
    TOP = const.MLINE_TOP
    ZERO = const.MLINE_ZERO
    BOTTOM = const.MLINE_BOTTOM
    HAS_VERTICES = const.MLINE_HAS_VERTICES
    CLOSED = const.MLINE_CLOSED
    SUPPRESS_START_CAPS = const.MLINE_SUPPRESS_START_CAPS
    SUPPRESS_END_CAPS = const.MLINE_SUPPRESS_END_CAPS

    def __init__(self) -> None:
        super().__init__()
        # The MLINE geometry stored in vertices, is the final geometry,
        # scaling factor, justification and MLineStyle settings are already
        # applied. This is why the geometry has to be updated every time a
        # change is applied.
        self.vertices: list[MLineVertex] = []

    def __len__(self):
        """Count of MLINE vertices."""
        return len(self.vertices)

    def _copy_data(self, entity: DXFEntity) -> None:
        assert isinstance(entity, MLine)
        entity.vertices = [v.copy() for v in self.vertices]

    def load_dxf_attribs(
        self, processor: Optional[SubclassProcessor] = None
    ) -> DXFNamespace:
        dxf = super().load_dxf_attribs(processor)
        if processor:
            tags = processor.fast_load_dxfattribs(
                dxf, acdb_mline_group_codes, 2, log=False
            )
            self.load_vertices(tags)
        return dxf

    def load_vertices(self, tags: Tags) -> None:
        self.vertices.extend(
            MLineVertex.load(tags) for tags in group_tags(tags, splitcode=11)
        )

    def preprocess_export(self, tagwriter: AbstractTagWriter) -> bool:
        # Do not export MLines without vertices
        return len(self.vertices) > 1
        # todo: check if line- and fill parametrization is compatible with
        #  MLINE style, requires same count of elements!

    def export_entity(self, tagwriter: AbstractTagWriter) -> None:
        # ezdxf does not export MLINE entities without vertices,
        # see method preprocess_export()
        self.set_flag_state(self.HAS_VERTICES, True)
        super().export_entity(tagwriter)
        tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_mline.name)
        self.dxf.export_dxf_attribs(tagwriter, acdb_mline.attribs.keys())
        self.export_vertices(tagwriter)

    def export_vertices(self, tagwriter: AbstractTagWriter) -> None:
        for vertex in self.vertices:
            vertex.export_dxf(tagwriter)

    @property
    def is_closed(self) -> bool:
        """Returns ``True`` if MLINE is closed.
        Compatibility interface to :class:`Polyline`
        """
        return self.get_flag_state(self.CLOSED)

    def close(self, state: bool = True) -> None:
        """Get/set closed state of MLINE and update geometry accordingly.
        Compatibility interface to :class:`Polyline`
        """
        state = bool(state)
        if state != self.is_closed:
            self.set_flag_state(self.CLOSED, state)
            self.update_geometry()

    @property
    def start_caps(self) -> bool:
        """Get/Set start caps state. ``True`` to enable start caps and
        ``False`` tu suppress start caps."""
        return not self.get_flag_state(self.SUPPRESS_START_CAPS)

    @start_caps.setter
    def start_caps(self, value: bool) -> None:
        """Set start caps state."""
        self.set_flag_state(self.SUPPRESS_START_CAPS, not bool(value))

    @property
    def end_caps(self) -> bool:
        """Get/Set end caps state. ``True`` to enable end caps and
        ``False`` tu suppress start caps."""
        return not self.get_flag_state(self.SUPPRESS_END_CAPS)

    @end_caps.setter
    def end_caps(self, value: bool) -> None:
        """Set start caps state."""
        self.set_flag_state(self.SUPPRESS_END_CAPS, not bool(value))

    def set_scale_factor(self, value: float) -> None:
        """Set the scale factor and update geometry accordingly."""
        value = float(value)
        if not math.isclose(self.dxf.scale_factor, value):
            self.dxf.scale_factor = value
            self.update_geometry()

    def set_justification(self, value: int) -> None:
        """Set MLINE justification and update geometry accordingly.
        See :attr:`dxf.justification` for valid settings.
        """
        value = int(value)
        if self.dxf.justification != value:
            self.dxf.justification = value
            self.update_geometry()

    @property
    def style(self) -> Optional[MLineStyle]:
        """Get associated MLINESTYLE."""
        if self.doc is None:
            return None
        _style = self.doc.entitydb.get(self.dxf.style_handle)
        if _style is None:
            _style = self.doc.mline_styles.get(self.dxf.style_name)
        return _style  # type: ignore

    def set_style(self, name: str) -> None:
        """Set MLINESTYLE by name and update geometry accordingly.
        The MLINESTYLE definition must exist.
        """
        if self.doc is None:
            logger.debug("Can't change style of unbounded MLINE entity.")
            return
        try:
            style = self.doc.mline_styles[name]
        except const.DXFKeyError:
            raise const.DXFValueError(f"Undefined MLINE style: {name}")
        assert isinstance(style, MLineStyle)
        # Line- and fill parametrization depends on the count of
        # elements, a change in the number of elements triggers a
        # reset of the parametrization:
        old_style = self.style
        new_element_count = len(style.elements)
        reset = False
        if old_style is not None:
            # Do not trust the stored "style_element_count" value
            reset = len(old_style.elements) != new_element_count

        self.dxf.style_name = name
        self.dxf.style_handle = style.dxf.handle
        self.dxf.style_element_count = new_element_count
        if reset:
            self.update_geometry()

    def start_location(self) -> Vec3:
        """Returns the start location of the reference line. Callback function
        for :attr:`dxf.start_location`.
        """
        if len(self.vertices):
            return self.vertices[0].location
        else:
            return NULLVEC

    def get_locations(self) -> list[Vec3]:
        """Returns the vertices of the reference line."""
        return [v.location for v in self.vertices]

    def extend(self, vertices: Iterable[UVec]) -> None:
        """Append multiple vertices to the reference line.

        It is possible to work with 3D vertices, but all vertices have to be in
        the same plane and the normal vector of this plan is stored as
        extrusion vector in the MLINE entity.

        """
        vertices = Vec3.list(vertices)
        if not vertices:
            return
        all_vertices = []
        if len(self):
            all_vertices.extend(self.get_locations())
        all_vertices.extend(vertices)
        self.generate_geometry(all_vertices)

    def update_geometry(self) -> None:
        """Regenerate the MLINE geometry based on current settings."""
        self.generate_geometry(self.get_locations())

    def generate_geometry(self, vertices: list[Vec3]) -> None:
        """Regenerate the MLINE geometry for new reference line defined by
        `vertices`.
        """
        vertices = list(filter_close_vertices(vertices, abs_tol=1e-6))
        if len(vertices) == 0:
            self.clear()
            return
        elif len(vertices) == 1:
            self.vertices = [MLineVertex.new(vertices[0], X_AXIS, Y_AXIS)]
            return

        style = self.style
        assert style is not None, "valid MLINE style required"
        if len(style.elements) == 0:
            raise const.DXFStructureError(
                f"No line elements defined in {str(style)}."
            )

        def miter(dir1: Vec3, dir2: Vec3):
            return ((dir1 + dir2) * 0.5).normalize().orthogonal()

        ucs = UCS.from_z_axis_and_point_in_xz(
            origin=vertices[0],
            point=vertices[1],
            axis=self.dxf.extrusion,
        )
        # Transform given vertices into UCS and project them into the
        # UCS-xy-plane by setting the z-axis to 0:
        vertices = [v.replace(z=0) for v in ucs.points_from_wcs(vertices)]
        start_angle = style.dxf.start_angle
        end_angle = style.dxf.end_angle

        line_directions = [
            (v2 - v1).normalize() for v1, v2 in zip(vertices, vertices[1:])
        ]

        if self.is_closed:
            line_directions.append((vertices[0] - vertices[-1]).normalize())
            closing_miter = miter(line_directions[0], line_directions[-1])
            miter_directions = [closing_miter]
        else:
            closing_miter = None
            line_directions.append(line_directions[-1])
            miter_directions = [line_directions[0].rotate_deg(start_angle)]

        for d1, d2 in zip(line_directions, line_directions[1:]):
            miter_directions.append(miter(d1, d2))

        if closing_miter is None:
            miter_directions.pop()
            miter_directions.append(line_directions[-1].rotate_deg(end_angle))
        else:
            miter_directions.append(closing_miter)

        self.vertices = [
            MLineVertex.new(v, d, m)
            for v, d, m in zip(vertices, line_directions, miter_directions)
        ]
        self._update_parametrization()

        # reverse transformation into WCS
        for v in self.vertices:
            v.transform(ucs.matrix)

    def _update_parametrization(self):
        scale = self.dxf.scale_factor
        style = self.style

        justification = self.dxf.justification
        offsets = [e.offset for e in style.elements]
        min_offset = min(offsets)
        max_offset = max(offsets)
        shift = 0
        if justification == self.TOP:
            shift = -max_offset
        elif justification == self.BOTTOM:
            shift = -min_offset

        for vertex in self.vertices:
            angle = vertex.line_direction.angle_between(vertex.miter_direction)
            try:
                stretch = scale / math.sin(angle)
            except ZeroDivisionError:
                stretch = 1.0
            vertex.line_params = [
                ((element.offset + shift) * stretch, 0.0)
                for element in style.elements
            ]
            vertex.fill_params = [tuple() for _ in style.elements]

    def clear(self) -> None:
        """Remove all MLINE vertices."""
        self.vertices.clear()

    def remove_dependencies(self, other: Optional[Drawing] = None) -> None:
        """Remove all dependencies from current document.

        (internal API)
        """
        if not self.is_alive:
            return

        super().remove_dependencies(other)
        self.dxf.style_handle = "0"
        if other:
            style = other.mline_styles.get(self.dxf.style_name)
            if style:
                self.dxf.style_handle = style.dxf.handle
                return
        self.dxf.style_name = "Standard"

    def transform(self, m: Matrix44) -> DXFGraphic:
        """Transform MLINE entity by transformation matrix `m` inplace."""
        for vertex in self.vertices:
            vertex.transform(m)
        self.dxf.extrusion = m.transform_direction(self.dxf.extrusion)
        scale = self.dxf.scale_factor
        scale_vec = m.transform_direction(Vec3(scale, scale, scale))
        if math.isclose(
            scale_vec.x, scale_vec.y, abs_tol=1e-6
        ) and math.isclose(scale_vec.y, scale_vec.z, abs_tol=1e-6):
            self.dxf.scale_factor = sum(scale_vec) / 3  # average error
        # None uniform scaling will not be applied to the scale_factor!
        self.update_geometry()
        self.post_transform(m)
        return self

    def __virtual_entities__(self) -> Iterator[DXFGraphic]:
        """Implements the SupportsVirtualEntities protocol.

        This protocol is for consistent internal usage and does not replace
        the method :meth:`virtual_entities`!
        """
        from ezdxf.render.mline import virtual_entities

        for e in virtual_entities(self):
            e.set_source_of_copy(self)
            yield e

    def virtual_entities(self) -> Iterator[DXFGraphic]:
        """Yields 'virtual' parts of MLINE as LINE, ARC and HATCH entities.

        These entities are located at the original positions, but are not stored
        in the entity database, have no handle and are not assigned to any
        layout.

        """
        return self.__virtual_entities__()

    def explode(
        self, target_layout: Optional[BaseLayout] = None
    ) -> EntityQuery:
        """Explode parts of MLINE as LINE, ARC and HATCH entities into target
        layout, if target layout is ``None``, the target layout is the layout
        of the MLINE.

        Returns an :class:`~ezdxf.query.EntityQuery` container with all DXF parts.

        Args:
            target_layout: target layout for DXF parts, ``None`` for same layout
                as source entity.
        """
        from ezdxf.explode import explode_entity

        return explode_entity(self, target_layout)

    def audit(self, auditor: Auditor) -> None:
        """Validity check."""

        def reset_mline_style(name="Standard"):
            auditor.fixed_error(
                code=AuditError.RESET_MLINE_STYLE,
                message=f'Reset MLINESTYLE to "{name}" in {str(self)}.',
                dxf_entity=self,
            )
            self.dxf.style_name = name
            style = doc.mline_styles.get(name)
            self.dxf.style_handle = style.dxf.handle

        super().audit(auditor)
        doc = auditor.doc
        if doc is None:
            return

        # Audit associated MLINESTYLE name and handle:
        style = doc.entitydb.get(self.dxf.style_handle)
        if style is None:  # handle is invalid, get style by name
            style = doc.mline_styles.get(self.dxf.style_name, None)
            if style is None:
                reset_mline_style()
            else:  # fix MLINESTYLE handle:
                auditor.fixed_error(
                    code=AuditError.INVALID_MLINESTYLE_HANDLE,
                    message=f"Fixed invalid style handle in {str(self)}.",
                    dxf_entity=self,
                )
                self.dxf.style_handle = style.dxf.handle
        else:  # update MLINESTYLE name silently
            self.dxf.style_name = style.dxf.name

        # Get current (maybe fixed) MLINESTYLE:
        style = self.style
        assert style is not None, "valid MLINE style required"

        # Update style element count silently:
        element_count = len(style.elements)
        self.dxf.style_element_count = element_count

        # Audit vertices:
        for vertex in self.vertices:
            if NULLVEC.isclose(vertex.line_direction):
                break
            if NULLVEC.isclose(vertex.miter_direction):
                break
            if len(vertex.line_params) != element_count:
                break
            # Ignore fill parameters.
        else:  # no break
            return

        # Invalid vertices found:
        auditor.fixed_error(
            code=AuditError.INVALID_MLINE_VERTEX,
            message=f"Execute geometry update for {str(self)}.",
            dxf_entity=self,
        )
        self.update_geometry()

    def ocs(self) -> OCS:
        # WCS entity which supports the "extrusion" attribute in a
        # different way!
        return OCS()


acdb_mline_style = DefSubclass(
    "AcDbMlineStyle",
    {
        "name": DXFAttr(2, default="Standard"),
        # Flags (bit-coded):
        # 1 =Fill on
        # 2 = Display miters
        # 16 = Start square end (line) cap
        # 32 = Start inner arcs cap
        # 64 = Start round (outer arcs) cap
        # 256 = End square (line) cap
        # 512 = End inner arcs cap
        # 1024 = End round (outer arcs) cap
        "flags": DXFAttr(70, default=0),
        # Style description (string, 255 characters maximum):
        "description": DXFAttr(3, default=""),
        # Fill color (integer, default = 256):
        "fill_color": DXFAttr(
            62,
            default=256,
            validator=validator.is_valid_aci_color,
            fixer=RETURN_DEFAULT,
        ),
        # Start angle (real, default is 90 degrees):
        "start_angle": DXFAttr(51, default=90),
        # End angle (real, default is 90 degrees):
        "end_angle": DXFAttr(52, default=90),
        # 71: Number of elements
        # 49: Element offset (real, no default).
        #     Multiple entries can exist; one entry for each element
        # 62: Element color (integer, default = 0).
        #     Multiple entries can exist; one entry for each element
        # 6:  Element linetype (string, default = BYLAYER).
        #     Multiple entries can exist; one entry for each element
    },
)
acdb_mline_style_group_codes = group_code_mapping(acdb_mline_style)
MLineStyleElement = namedtuple("MLineStyleElement", "offset color linetype")


class MLineStyleElements:
    def __init__(self, tags: Optional[Tags] = None):
        self.elements: list[MLineStyleElement] = []
        if tags:
            for e in self.parse_tags(tags):
                data = MLineStyleElement(
                    e.get("offset", 1.0),
                    e.get("color", 0),
                    e.get("linetype", "BYLAYER"),
                )
                self.elements.append(data)

    def __len__(self):
        return len(self.elements)

    def __getitem__(self, item):
        return self.elements[item]

    def __iter__(self):
        return iter(self.elements)

    def export_dxf(self, tagwriter: AbstractTagWriter):
        write_tag = tagwriter.write_tag2
        write_tag(71, len(self.elements))
        for offset, color, linetype in self.elements:
            write_tag(49, offset)
            write_tag(62, color)
            write_tag(6, linetype)

    def append(
        self, offset: float, color: int = 0, linetype: str = "BYLAYER"
    ) -> None:
        """Append a new line element.

        Args:
            offset: normal offset from the reference line: if justification is
                ``MLINE_ZERO``, positive values are above and negative values
                are below the reference line.
            color: :ref:`ACI` value
            linetype: linetype name

        """
        self.elements.append(
            MLineStyleElement(float(offset), int(color), str(linetype))
        )

    @staticmethod
    def parse_tags(tags: Tags) -> Iterator[dict]:
        collector = None
        for code, value in tags:
            if code == 49:
                if collector is not None:
                    yield collector
                collector = {"offset": value}
            elif code == 62:
                collector["color"] = value  # type: ignore
            elif code == 6:
                collector["linetype"] = value  # type: ignore
        if collector is not None:
            yield collector

    def ordered_indices(self) -> list[int]:
        offsets = [e.offset for e in self.elements]
        return [offsets.index(value) for value in sorted(offsets)]


@register_entity
class MLineStyle(DXFObject):
    DXFTYPE = "MLINESTYLE"
    DXFATTRIBS = DXFAttributes(base_class, acdb_mline_style)
    FILL = const.MLINESTYLE_FILL
    MITER = const.MLINESTYLE_MITER
    START_SQUARE = const.MLINESTYLE_START_SQUARE
    START_INNER_ARC = const.MLINESTYLE_START_INNER_ARC
    START_ROUND = const.MLINESTYLE_START_ROUND
    END_SQUARE = const.MLINESTYLE_END_SQUARE
    END_INNER_ARC = const.MLINESTYLE_END_INNER_ARC
    END_ROUND = const.MLINESTYLE_END_ROUND

    def __init__(self):
        super().__init__()
        self.elements = MLineStyleElements()

    def copy(self):
        raise const.DXFTypeError("Copying of MLINESTYLE not supported.")

    def load_dxf_attribs(
        self, processor: Optional[SubclassProcessor] = None
    ) -> DXFNamespace:
        dxf = super().load_dxf_attribs(processor)
        if processor:
            tags = processor.subclass_by_index(1)
            if tags is None:
                raise const.DXFStructureError(
                    f"missing 'AcDbMLine' subclass in MLINE(#{dxf.handle})"
                )

            try:
                # Find index of the count tag:
                index71 = tags.tag_index(71)
            except const.DXFValueError:
                # The count tag does not exist: DXF structure error?
                pass
            else:
                self.elements = MLineStyleElements(tags[index71 + 1 :])  # type: ignore
                # Remove processed tags:
                del tags[index71:]
            processor.fast_load_dxfattribs(
                dxf, acdb_mline_style_group_codes, tags
            )
        return dxf

    def export_entity(self, tagwriter: AbstractTagWriter) -> None:
        super().export_entity(tagwriter)
        tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_mline_style.name)
        self.dxf.export_dxf_attribs(tagwriter, acdb_mline_style.attribs.keys())
        self.elements.export_dxf(tagwriter)

    def update_all(self):
        """Update all MLINE entities using this MLINESTYLE.

        The update is required if elements were added or removed or the offset
        of any element was changed.

        """
        if self.doc:
            handle = self.dxf.handle
            mlines = (
                e for e in self.doc.entitydb.values() if e.dxftype() == "MLINE"
            )
            for mline in mlines:
                if mline.dxf.style_handle == handle:
                    mline.update_geometry()

    def ordered_indices(self) -> list[int]:
        return self.elements.ordered_indices()

    def audit(self, auditor: Auditor) -> None:
        super().audit(auditor)
        if len(self.elements) == 0:
            auditor.add_error(
                code=AuditError.INVALID_MLINESTYLE_ELEMENT_COUNT,
                message=f"No line elements defined in {str(self)}.",
                dxf_entity=self,
            )


class MLineStyleCollection(ObjectCollection[MLineStyle]):
    def __init__(self, doc: Drawing):
        super().__init__(
            doc, dict_name="ACAD_MLINESTYLE", object_type="MLINESTYLE"
        )
        self.create_required_entries()

    def create_required_entries(self) -> None:
        if "Standard" not in self:
            entity: MLineStyle = self.new("Standard")  # type: ignore
            entity.elements.append(0.5, 256)
            entity.elements.append(-0.5, 256)
