# Copyright (c) 2019-2023 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
    TYPE_CHECKING,
    Iterable,
    Iterator,
    cast,
    Union,
    Optional,
    Callable,
)
import math
from ezdxf.lldxf import validator
from ezdxf.lldxf.attributes import (
    DXFAttr,
    DXFAttributes,
    DefSubclass,
    XType,
    RETURN_DEFAULT,
    group_code_mapping,
    merge_group_code_mappings,
)
from ezdxf.lldxf.const import (
    DXF12,
    SUBCLASS_MARKER,
    DXFValueError,
    DXFKeyError,
    DXFStructureError,
)
from ezdxf.math import (
    Vec3,
    UVec,
    X_AXIS,
    Y_AXIS,
    Z_AXIS,
    Matrix44,
    UCS,
    NULLVEC,
)
from ezdxf.math.transformtools import (
    InsertTransformationError,
    InsertCoordinateSystem,
)
from ezdxf.explode import (
    explode_block_reference,
    virtual_block_reference_entities,
)
from ezdxf.entities import factory
from ezdxf.query import EntityQuery
from ezdxf.audit import AuditError
from .dxfentity import base_class, SubclassProcessor
from .dxfgfx import (
    DXFGraphic,
    acdb_entity,
    elevation_to_z_axis,
    acdb_entity_group_codes,
)
from .subentity import LinkedEntities
from .attrib import Attrib

if TYPE_CHECKING:
    from ezdxf.audit import Auditor
    from ezdxf.entities import DXFNamespace, AttDef, DXFEntity
    from ezdxf.layouts import BaseLayout, BlockLayout
    from ezdxf.lldxf.tagwriter import AbstractTagWriter
    from ezdxf import xref

__all__ = ["Insert"]

ABS_TOL = 1e-9

# DXF files as XREF:
# The INSERT entity is used to attach XREFS.
# The model space is the block content, if the whole document is used as
# BLOCK (XREF), but this is only supported for the DWG format.
# AutoCAD does not support DXF files as XREFS, they are ignored, but the DXF
# file is valid! BricsCAD shows DXF files as XREFS, but does not allow to attach
# DXF files as XREFS by the application itself.

# Multi-INSERT has subclass id AcDbMInsertBlock
acdb_block_reference = DefSubclass(
    "AcDbBlockReference",
    {
        "attribs_follow": DXFAttr(66, default=0, optional=True),
        "name": DXFAttr(2, validator=validator.is_valid_block_name),
        "insert": DXFAttr(10, xtype=XType.any_point),
        # Elevation is a legacy feature from R11 and prior, do not use this
        # attribute, store the entity elevation in the z-axis of the vertices.
        # ezdxf does not export the elevation attribute!
        "elevation": DXFAttr(38, default=0, optional=True),
        "xscale": DXFAttr(
            41,
            default=1,
            optional=True,
            validator=validator.is_not_zero,
            fixer=RETURN_DEFAULT,
        ),
        "yscale": DXFAttr(
            42,
            default=1,
            optional=True,
            validator=validator.is_not_zero,
            fixer=RETURN_DEFAULT,
        ),
        "zscale": DXFAttr(
            43,
            default=1,
            optional=True,
            validator=validator.is_not_zero,
            fixer=RETURN_DEFAULT,
        ),
        "rotation": DXFAttr(50, default=0, optional=True),
        "column_count": DXFAttr(
            70,
            default=1,
            optional=True,
            validator=validator.is_greater_zero,
            fixer=RETURN_DEFAULT,
        ),
        "row_count": DXFAttr(
            71,
            default=1,
            optional=True,
            validator=validator.is_greater_zero,
            fixer=RETURN_DEFAULT,
        ),
        "column_spacing": DXFAttr(44, default=0, optional=True),
        "row_spacing": DXFAttr(45, default=0, optional=True),
        "extrusion": DXFAttr(
            210,
            xtype=XType.point3d,
            default=Z_AXIS,
            optional=True,
            validator=validator.is_not_null_vector,
            fixer=RETURN_DEFAULT,
        ),
    },
)
acdb_block_reference_group_codes = group_code_mapping(acdb_block_reference)
merged_insert_group_codes = merge_group_code_mappings(
    acdb_entity_group_codes, acdb_block_reference_group_codes  # type: ignore
)


# Notes to SEQEND:
#
# The INSERT entity requires only a SEQEND if ATTRIB entities are attached.
#  So a loaded INSERT could have a missing SEQEND.
#
# A bounded INSERT needs a SEQEND to be valid at export if there are attached
# ATTRIB entities, but the LinkedEntities.post_bind_hook() method creates
# always a new SEQEND after binding the INSERT entity to a document.
#
# Nonetheless, the Insert.add_attrib() method also creates the required SEQEND entity if
# necessary.


@factory.register_entity
class Insert(LinkedEntities):
    """DXF INSERT entity

    The INSERT entity is hard owner of its ATTRIB entities and the SEQEND entity:

        ATTRIB.dxf.owner == INSERT.dxf.handle
        SEQEND.dxf.owner == INSERT.dxf.handle

    Note:

        The ATTDEF entity in block definitions is owned by the BLOCK_RECORD like
        all graphical entities.

    """

    DXFTYPE = "INSERT"
    DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_block_reference)

    @property
    def attribs(self) -> list[Attrib]:
        return self._sub_entities  # type: ignore

    @property
    def attribs_follow(self) -> bool:
        return bool(len(self.attribs))

    def load_dxf_attribs(
        self, processor: Optional[SubclassProcessor] = None
    ) -> DXFNamespace:
        """Loading interface. (internal API)"""
        # bypass DXFGraphic, loading proxy graphic is skipped!
        dxf = super(DXFGraphic, self).load_dxf_attribs(processor)
        if processor:
            processor.simple_dxfattribs_loader(dxf, merged_insert_group_codes)
            if processor.r12:
                # Transform elevation attribute from R11 to z-axis values:
                elevation_to_z_axis(dxf, ("insert",))
        return dxf

    def export_entity(self, tagwriter: AbstractTagWriter) -> None:
        """Export entity specific data as DXF tags."""
        super().export_entity(tagwriter)
        if tagwriter.dxfversion > DXF12:
            if (self.dxf.column_count > 1) or (self.dxf.row_count > 1):
                tagwriter.write_tag2(SUBCLASS_MARKER, "AcDbMInsertBlock")
            else:
                tagwriter.write_tag2(SUBCLASS_MARKER, "AcDbBlockReference")
        if self.attribs_follow:
            tagwriter.write_tag2(66, 1)
        self.dxf.export_dxf_attribs(
            tagwriter,
            [
                "name",
                "insert",
                "xscale",
                "yscale",
                "zscale",
                "rotation",
                "column_count",
                "row_count",
                "column_spacing",
                "row_spacing",
                "extrusion",
            ],
        )

    def export_dxf(self, tagwriter: AbstractTagWriter):
        super().export_dxf(tagwriter)
        # Do no export SEQEND if no ATTRIBS attached:
        if self.attribs_follow:
            self.process_sub_entities(lambda e: e.export_dxf(tagwriter))

    def register_resources(self, registry: xref.Registry) -> None:
        # The attached ATTRIB entities are registered by the parent class LinkedEntities
        super().register_resources(registry)
        registry.add_block_name(self.dxf.name)

    def map_resources(self, clone: DXFEntity, mapping: xref.ResourceMapper) -> None:
        # The attached ATTRIB entities are mapped by the parent class LinkedEntities
        super().map_resources(clone, mapping)
        clone.dxf.name = mapping.get_block_name(self.dxf.name)

    @property
    def has_scaling(self) -> bool:
        """Returns ``True`` if scaling is applied to any axis."""
        if self.dxf.hasattr("xscale") and self.dxf.xscale != 1:
            return True
        if self.dxf.hasattr("yscale") and self.dxf.yscale != 1:
            return True
        if self.dxf.hasattr("zscale") and self.dxf.zscale != 1:
            return True
        return False

    @property
    def has_uniform_scaling(self) -> bool:
        """Returns ``True`` if the scale factor is uniform for x-, y- and z-axis,
        ignoring reflections e.g. (1, 1, -1) is uniform scaling.

        """
        return abs(self.dxf.xscale) == abs(self.dxf.yscale) == abs(self.dxf.zscale)

    def set_scale(self, factor: float):
        """Set a uniform scale factor."""
        if factor == 0:
            raise ValueError("Invalid scale factor.")
        self.dxf.xscale = factor
        self.dxf.yscale = factor
        self.dxf.zscale = factor
        return self

    def block(self) -> Optional[BlockLayout]:
        """Returns the associated :class:`~ezdxf.layouts.BlockLayout`."""
        if self.doc:
            return self.doc.blocks.get(self.dxf.name)
        return None

    def is_xref(self) -> bool:
        """Return ``True`` if the INSERT entity represents a XREF or XREF_OVERLAY."""
        block = self.block()
        if block is not None:
            return block.block_record.is_xref
        return False

    def place(
        self,
        insert: Optional[UVec] = None,
        scale: Optional[tuple[float, float, float]] = None,
        rotation: Optional[float] = None,
    ) -> Insert:
        """
        Set the location, scaling and rotation attributes. Arguments which are ``None``
        will be ignored.

        Args:
            insert: insert location as (x, y [,z]) tuple
            scale: (x-scale, y-scale, z-scale) tuple
            rotation : rotation angle in degrees

        """
        if insert is not None:
            self.dxf.insert = insert
        if scale is not None:
            if len(scale) != 3:
                raise DXFValueError("Argument scale has to be a (x, y[, z]) tuple.")
            x, y, z = scale
            self.dxf.xscale = x
            self.dxf.yscale = y
            self.dxf.zscale = z
        if rotation is not None:
            self.dxf.rotation = rotation
        return self

    def grid(
        self, size: tuple[int, int] = (1, 1), spacing: tuple[float, float] = (1, 1)
    ) -> Insert:
        """Place block reference in a grid layout, grid `size` defines the
        row- and column count, `spacing` defines the distance between two block
        references.

        Args:
            size: grid size as (row_count, column_count) tuple
            spacing: distance between placing as (row_spacing, column_spacing) tuple

        """
        try:
            rows, cols = size
        except ValueError:
            raise DXFValueError("Size has to be a (row_count, column_count) tuple.")
        self.dxf.row_count = rows
        self.dxf.column_count = cols
        try:
            row_spacing, col_spacing = spacing
        except ValueError:
            raise DXFValueError(
                "Spacing has to be a (row_spacing, column_spacing) tuple."
            )
        self.dxf.row_spacing = row_spacing
        self.dxf.column_spacing = col_spacing
        return self

    def get_attrib(
        self, tag: str, search_const: bool = False
    ) -> Optional[Union[Attrib, AttDef]]:
        """Get an attached :class:`Attrib` entity with the given `tag`,
        returns ``None`` if not found.  Some applications do not attach constant
        ATTRIB entities, set `search_const` to ``True``, to get at least the
        associated :class:`AttDef` entity.

        Args:
            tag: tag name of the ATTRIB entity
            search_const: search also const ATTDEF entities

        """
        for attrib in self.attribs:
            if tag == attrib.dxf.tag:
                return attrib
        if search_const and self.doc is not None:
            block = self.doc.blocks[self.dxf.name]
            for attdef in block.get_const_attdefs():
                if tag == attdef.dxf.tag:
                    return attdef
        return None

    def get_attrib_text(
        self, tag: str, default: str = "", search_const: bool = False
    ) -> str:
        """Get content text of an attached :class:`Attrib` entity with
        the given `tag`, returns the `default` value if not found.
        Some applications do not attach constant ATTRIB entities, set
        `search_const` to ``True``, to get content text of the
        associated :class:`AttDef` entity.

        Args:
            tag: tag name of the ATTRIB entity
            default: default value if ATTRIB `tag` is absent
            search_const: search also const ATTDEF entities

        """
        attrib = self.get_attrib(tag, search_const)
        if attrib is None:
            return default
        return attrib.dxf.text

    def has_attrib(self, tag: str, search_const: bool = False) -> bool:
        """Returns ``True`` if the INSERT entity has an attached ATTRIB entity with the
        given `tag`.  Some applications do not attach constant ATTRIB entities, set
        `search_const` to ``True``, to check for an associated :class:`AttDef` entity
        with constant content.


        Args:
            tag: tag name fo the ATTRIB entity
            search_const: search also const ATTDEF entities

        """
        return self.get_attrib(tag, search_const) is not None

    def add_attrib(
        self, tag: str, text: str, insert: UVec = (0, 0), dxfattribs=None
    ) -> Attrib:
        """Attach an :class:`Attrib` entity to the block reference.

        Example for appending an attribute to an INSERT entity::

            e.add_attrib('EXAMPLETAG', 'example text').set_placement(
                (3, 7), align=TextEntityAlignment.MIDDLE_CENTER
            )

        Args:
            tag: tag name of the ATTRIB entity
            text: content text as string
            insert: insert location as (x, y[, z]) tuple in :ref:`OCS`
            dxfattribs: additional DXF attributes for the ATTRIB entity

        """
        dxfattribs = dict(dxfattribs or {})
        dxfattribs["tag"] = tag
        dxfattribs["text"] = text
        dxfattribs["insert"] = insert
        attrib = cast("Attrib", self._new_compound_entity("ATTRIB", dxfattribs))
        self.attribs.append(attrib)

        # This case is only possible if the INSERT was read from a file without
        # attached ATTRIB entities:
        if self.seqend is None:
            self.new_seqend()
        return attrib

    def delete_attrib(self, tag: str, ignore=False) -> None:
        """Delete an attached :class:`Attrib` entity from INSERT. Raises an
        :class:`DXFKeyError` exception, if no ATTRIB for the given `tag` exist if
        `ignore` is ``False``.

        Args:
            tag: tag name of the ATTRIB entity
            ignore: ``False`` for raising :class:`DXFKeyError` if ATTRIB `tag`
                does not exist.

        Raises:
            DXFKeyError: no ATTRIB for the given `tag` exist

        """
        for index, attrib in enumerate(self.attribs):
            if attrib.dxf.tag == tag:
                del self.attribs[index]
                attrib.destroy()
                return
        if not ignore:
            raise DXFKeyError(tag)

    def delete_all_attribs(self) -> None:
        """Delete all :class:`Attrib` entities attached to the INSERT entity."""
        if not self.is_alive:
            return

        for attrib in self.attribs:
            attrib.destroy()
        self._sub_entities = []

    def transform(self, m: Matrix44) -> Insert:
        """Transform INSERT entity by transformation matrix `m` inplace.

        Unlike the transformation matrix `m`, the INSERT entity can not
        represent a non-orthogonal target coordinate system and an
        :class:`InsertTransformationError` will be raised in that case.

        """
        dxf = self.dxf
        source_system = InsertCoordinateSystem(
            insert=Vec3(dxf.insert),
            scale=(dxf.xscale, dxf.yscale, dxf.zscale),
            rotation=dxf.rotation,
            extrusion=dxf.extrusion,
        )
        try:
            target_system = source_system.transform(m, ABS_TOL)
        except InsertTransformationError:
            raise InsertTransformationError(
                "INSERT entity can not represent a non-orthogonal target coordinate system."
            )
        dxf.insert = target_system.insert
        dxf.rotation = target_system.rotation
        dxf.extrusion = target_system.extrusion
        dxf.xscale = target_system.scale_factor_x
        dxf.yscale = target_system.scale_factor_y
        dxf.zscale = target_system.scale_factor_z

        for attrib in self.attribs:
            attrib.transform(m)
        self.post_transform(m)
        return self

    def translate(self, dx: float, dy: float, dz: float) -> Insert:
        """Optimized INSERT translation about `dx` in x-axis, `dy` in y-axis
        and `dz` in z-axis.

        """
        ocs = self.ocs()
        self.dxf.insert = ocs.from_wcs(Vec3(dx, dy, dz) + ocs.to_wcs(self.dxf.insert))
        for attrib in self.attribs:
            attrib.translate(dx, dy, dz)
        return self

    def matrix44(self) -> Matrix44:
        """Returns a transformation matrix to transform the block entities from the
        block reference coordinate system into the :ref:`WCS`.
        """
        dxf = self.dxf
        sx = dxf.xscale
        sy = dxf.yscale
        sz = dxf.zscale

        ocs = self.ocs()
        extrusion = ocs.uz
        ux = Vec3(ocs.to_wcs(X_AXIS))
        uy = Vec3(ocs.to_wcs(Y_AXIS))
        m = Matrix44.ucs(ux=ux * sx, uy=uy * sy, uz=extrusion * sz)
        # same as Matrix44.ucs(ux, uy, extrusion) * Matrix44.scale(sx, sy, sz)

        angle = math.radians(dxf.rotation)
        if angle:
            m *= Matrix44.axis_rotate(extrusion, angle)

        insert = ocs.to_wcs(dxf.get("insert", NULLVEC))

        block_layout = self.block()
        if block_layout is not None:
            # transform block base point into WCS without translation
            insert -= m.transform_direction(block_layout.block.dxf.base_point)  # type: ignore

        # set translation
        m.set_row(3, insert.xyz)
        return m

    def ucs(self):
        """Returns the block reference coordinate system as :class:`ezdxf.math.UCS`
        object.
        """
        m = self.matrix44()
        ucs = UCS()
        ucs.matrix = m
        return ucs

    def reset_transformation(self) -> None:
        """Reset block reference attributes location, rotation angle and
        the extrusion vector but preserves the scale factors.

        """
        self.dxf.insert = NULLVEC
        self.dxf.discard("rotation")
        self.dxf.discard("extrusion")

    def explode(
        self, target_layout: Optional[BaseLayout] = None, *, redraw_order=False
    ) -> EntityQuery:
        """Explodes the block reference entities into the target layout, if target
        layout is ``None``, the layout of the block reference will be used.
        This method destroys the source block reference entity.

        Transforms the block entities into the required :ref:`WCS` location by
        applying the block reference attributes `insert`, `extrusion`,
        `rotation` and the scale factors `xscale`, `yscale` and `zscale`.

        Attached ATTRIB entities are converted to TEXT entities, this is the
        behavior of the BURST command of the AutoCAD Express Tools.

        .. warning::

            **Non-uniform scale factors** may lead to incorrect results some entities
            (TEXT, MTEXT, ATTRIB).

        Args:
            target_layout: target layout for exploded entities, ``None`` for
                same layout as source entity.
            redraw_order: create entities in ascending redraw order if ``True``

        Returns:
            :class:`~ezdxf.query.EntityQuery` container referencing all exploded
            DXF entities.

        """
        if target_layout is None:
            target_layout = self.get_layout()
            if target_layout is None:
                raise DXFStructureError(
                    "INSERT without layout assignment, specify target layout"
                )
        return explode_block_reference(
            self, target_layout=target_layout, redraw_order=redraw_order
        )

    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`! Ignores the redraw-order!
        """
        return self.virtual_entities()

    def virtual_entities(
        self,
        *,
        skipped_entity_callback: Optional[Callable[[DXFGraphic, str], None]] = None,
        redraw_order=False,
    ) -> Iterator[DXFGraphic]:
        """
        Yields the transformed referenced block content as virtual entities.

        This method is meant to examine the block reference entities at the target
        location without exploding the block reference.
        These entities are not stored in the entity database, have no handle and
        are not assigned to any layout. It is possible to convert these entities
        into regular drawing entities by adding the entities to the entities
        database and a layout of the same DXF document as the block reference::

            doc.entitydb.add(entity)
            msp = doc.modelspace()
            msp.add_entity(entity)

        .. warning::

            **Non-uniform scale factors** may return incorrect results for some entities
            (TEXT, MTEXT, ATTRIB).

        This method does not resolve the MINSERT attributes, only the
        sub-entities of the first INSERT will be returned. To resolve MINSERT
        entities check if multi insert processing is required, that's the case
        if the property :attr:`Insert.mcount` > 1, use the :meth:`Insert.multi_insert`
        method to resolve the MINSERT entity into multiple INSERT entities.

        This method does not apply the clipping path created by the XCLIP command. 
        The method returns all entities and ignores the clipping path polygon and no 
        entity is clipped.

        The `skipped_entity_callback()` will be called for all entities which are not
        processed, signature:
        :code:`skipped_entity_callback(entity: DXFEntity, reason: str)`,
        `entity` is the original (untransformed) DXF entity of the block definition, the
        `reason` string is an explanation why the entity was skipped.

        Args:
            skipped_entity_callback: called whenever the transformation of an
                entity is not supported and so was skipped
            redraw_order: yield entities in ascending redraw order if ``True``

        """
        for e in virtual_block_reference_entities(
            self,
            skipped_entity_callback=skipped_entity_callback,
            redraw_order=redraw_order,
        ):
            e.set_source_block_reference(self)
            yield e

    @property
    def mcount(self) -> int:
        """Returns the multi-insert count, MINSERT (multi-insert) processing
        is required if :attr:`mcount` > 1.

        """
        return (self.dxf.row_count if self.dxf.row_spacing else 1) * (
            self.dxf.column_count if self.dxf.column_spacing else 1
        )

    def multi_insert(self) -> Iterator[Insert]:
        """Yields a virtual INSERT entity for each grid element of a MINSERT
        entity (multi-insert).
        """

        def transform_attached_attrib_entities(insert, offset):
            for attrib in insert.attribs:
                attrib.dxf.insert += offset

        def adjust_dxf_attribs(insert, offset):
            dxf = insert.dxf
            dxf.insert += offset
            dxf.discard("row_count")
            dxf.discard("column_count")
            dxf.discard("row_spacing")
            dxf.discard("column_spacing")

        done = set()
        row_spacing = self.dxf.row_spacing
        col_spacing = self.dxf.column_spacing
        rotation = self.dxf.rotation
        for row in range(self.dxf.row_count):
            for col in range(self.dxf.column_count):
                # All transformations in OCS:
                offset = Vec3(col * col_spacing, row * row_spacing)
                # If any spacing is 0, yield only unique locations:
                if offset not in done:
                    done.add(offset)
                    if rotation:  # Apply rotation to the grid.
                        offset = offset.rotate_deg(rotation)
                    # Do not apply scaling to the grid!
                    insert = self.copy()
                    adjust_dxf_attribs(insert, offset)
                    transform_attached_attrib_entities(insert, offset)
                    yield insert

    def add_auto_attribs(self, values: dict[str, str]) -> Insert:
        """
        Attach for each :class:`~ezdxf.entities.Attdef` entity, defined in the
        block definition, automatically an :class:`Attrib` entity to the block
        reference and set ``tag/value`` DXF attributes of the ATTRIB entities
        by the ``key/value`` pairs (both as strings) of the `values` dict.
        The ATTRIB entities are placed relative to the insert location of the
        block reference, which is identical to the block base point.

        This method avoids the wrapper block of the
        :meth:`~ezdxf.layouts.BaseLayout.add_auto_blockref` method, but the
        visual results may not match the results of CAD applications, especially
        for non-uniform scaling. If the visual result is very important to you,
        use the :meth:`add_auto_blockref` method.

        Args:
            values: :class:`~ezdxf.entities.Attrib` tag values as ``tag/value``
                pairs

        """

        def unpack(dxfattribs) -> tuple[str, str, UVec]:
            tag = dxfattribs.pop("tag")
            text = values.get(tag, "")
            location = dxfattribs.pop("insert")
            return tag, text, location

        def autofill() -> None:
            for attdef in block_layout.attdefs():  # type: ignore
                dxfattribs = attdef.dxfattribs(drop={"prompt", "handle"})
                tag, text, location = unpack(dxfattribs)
                attrib = self.add_attrib(tag, text, location, dxfattribs)
                attrib.transform(m)

        block_layout = self.block()
        if block_layout is not None:
            m = self.matrix44()
            autofill()
        return self

    def audit(self, auditor: Auditor) -> None:
        """Validity check."""
        super().audit(auditor)
        doc = auditor.doc
        if doc and doc.blocks:
            name = self.dxf.name
            if name is None:
                auditor.fixed_error(
                    code=AuditError.UNDEFINED_BLOCK_NAME,
                    message=f"Deleted entity {str(self)} without a BLOCK name",
                )
                auditor.trash(self)
            elif name not in doc.blocks:
                auditor.fixed_error(
                    code=AuditError.UNDEFINED_BLOCK,
                    message=f"Deleted entity {str(self)} without required BLOCK"
                    f" definition.",
                )
                auditor.trash(self)

    def __referenced_blocks__(self) -> Iterable[str]:
        """Support for the "ReferencedBlocks" protocol."""
        block = self.block()
        if block is not None:
            return (block.block_record_handle,)
        return tuple()
