# Copyright (c) 2019-2021 Manfred Moitzi
# License: MIT License
from typing import TYPE_CHECKING, Optional, Tuple, Iterable, Dict, Any
from ezdxf.entities import factory
from ezdxf import options
from ezdxf.lldxf import validator
from ezdxf.lldxf.attributes import (
    DXFAttr,
    DXFAttributes,
    DefSubclass,
    RETURN_DEFAULT,
    group_code_mapping,
)
from ezdxf import colors as clr
from ezdxf.lldxf.const import (
    DXF12,
    DXF2000,
    DXF2004,
    DXF2007,
    DXF2013,
    DXFValueError,
    DXFKeyError,
    DXFTableEntryError,
    SUBCLASS_MARKER,
    DXFInvalidLineType,
    DXFStructureError,
    TRANSPARENCY_BYBLOCK,
)
from ezdxf.math import OCS, Matrix44
from ezdxf.proxygraphic import load_proxy_graphic, export_proxy_graphic
from .dxfentity import DXFEntity, base_class, SubclassProcessor, DXFTagStorage

if TYPE_CHECKING:
    from ezdxf.eztypes import (
        Auditor,
        TagWriter,
        BaseLayout,
        DXFNamespace,
        Vertex,
        Drawing,
    )

__all__ = [
    "DXFGraphic",
    "acdb_entity",
    "SeqEnd",
    "add_entity",
    "replace_entity",
    "elevation_to_z_axis",
    "is_graphic_entity",
    "get_font_name",
]

GRAPHIC_PROPERTIES = {
    "layer",
    "linetype",
    "color",
    "lineweight",
    "ltscale",
    "true_color",
    "color_name",
    "transparency",
}

acdb_entity: DefSubclass = DefSubclass(
    "AcDbEntity",
    {
        # Layer name as string, no auto fix for invalid names!
        "layer": DXFAttr(
            8, default="0", validator=validator.is_valid_layer_name
        ),
        # Linetype name as string, no auto fix for invalid names!
        "linetype": DXFAttr(
            6,
            default="BYLAYER",
            optional=True,
            validator=validator.is_valid_table_name,
        ),
        # ACI color index, BYBLOCK=0, BYLAYER=256, BYOBJECT=257:
        "color": DXFAttr(
            62,
            default=256,
            optional=True,
            validator=validator.is_valid_aci_color,
            fixer=RETURN_DEFAULT,
        ),
        # modelspace=0, paperspace=1
        "paperspace": DXFAttr(
            67,
            default=0,
            optional=True,
            validator=validator.is_integer_bool,
            fixer=RETURN_DEFAULT,
        ),
        # Lineweight in mm times 100 (e.g. 0.13mm = 13). Smallest line weight is 13
        # and biggest line weight is 200, values outside this range prevents AutoCAD
        # from loading the file.
        # Special values: BYLAYER=-1, BYBLOCK=-2, DEFAULT=-3
        "lineweight": DXFAttr(
            370,
            default=-1,
            dxfversion=DXF2000,
            optional=True,
            validator=validator.is_valid_lineweight,
            fixer=validator.fix_lineweight,
        ),
        "ltscale": DXFAttr(
            48,
            default=1.0,
            dxfversion=DXF2000,
            optional=True,
            validator=validator.is_positive,
            fixer=RETURN_DEFAULT,
        ),
        # visible=0, invisible=1
        "invisible": DXFAttr(60, default=0, dxfversion=DXF2000, optional=True),
        # True color as 0x00RRGGBB 24-bit value
        # True color always overrides ACI "color"!
        "true_color": DXFAttr(420, dxfversion=DXF2004, optional=True),
        # Color name as string. Color books are stored in .stb config files?
        "color_name": DXFAttr(430, dxfversion=DXF2004, optional=True),
        # Transparency value 0x020000TT 0 = fully transparent / 255 = opaque
        # Special value 0x01000000 == ByBlock
        # unset value means ByLayer
        "transparency": DXFAttr(
            440,
            dxfversion=DXF2004,
            optional=True,
            validator=validator.is_transparency,
        ),
        # Shadow mode:
        # 0 = Casts and receives shadows
        # 1 = Casts shadows
        # 2 = Receives shadows
        # 3 = Ignores shadows
        "shadow_mode": DXFAttr(284, dxfversion=DXF2007, optional=True),
        "material_handle": DXFAttr(347, dxfversion=DXF2007, optional=True),
        "visualstyle_handle": DXFAttr(348, dxfversion=DXF2007, optional=True),
        # PlotStyleName type enum (AcDb::PlotStyleNameType). Stored and moved around
        # as a 16-bit integer. Custom non-entity
        "plotstyle_enum": DXFAttr(
            380, dxfversion=DXF2007, default=1, optional=True
        ),
        # Handle value of the PlotStyleName object, basically a hard pointer, but
        # has a different range to make backward compatibility easier to deal with.
        "plotstyle_handle": DXFAttr(390, dxfversion=DXF2007, optional=True),
        # 92 or 160?: Number of bytes in the proxy entity graphics represented in
        # the subsequent 310 groups, which are binary chunk records (optional)
        # 310: Proxy entity graphics data (multiple lines; 256 characters max. per
        # line) (optional), compiled by TagCompiler() to a DXFBinaryTag() objects
    },
)
acdb_entity_group_codes = group_code_mapping(acdb_entity)


def elevation_to_z_axis(dxf: "DXFNamespace", names: Iterable[str]):
    # The elevation group code (38) is only used for DXF R11 and prior and
    # ignored for DXF R2000 and later.
    # DXF R12 and later store the entity elevation in the z-axis of the
    # vertices, but AutoCAD supports elevation for R12 if no z-axis is present.
    # DXF types with legacy elevation support:
    # SOLID, TRACE, TEXT, CIRCLE, ARC, TEXT, ATTRIB, ATTDEF, INSERT, SHAPE

    # The elevation is only used for DXF R12 if no z-axis is stored in the DXF
    # file. This is a problem because ezdxf loads the vertices always as 3D
    # vertex including a z-axis even if no z-axis is present in DXF file.
    if dxf.hasattr("elevation"):
        elevation = dxf.elevation
        # ezdxf does not export the elevation attribute for any DXF version
        dxf.discard("elevation")
        if elevation == 0:
            return

        for name in names:
            v = dxf.get(name)
            # Only use elevation value if z-axis is 0, this will not work for
            # situations where an elevation and a z-axis=0 is present, but let's
            # assume if the elevation group code is used the z-axis is not
            # present if z-axis is 0.
            if v is not None and v.z == 0:
                dxf.set(name, v.replace(z=elevation))


class DXFGraphic(DXFEntity):
    """Common base class for all graphic entities, a subclass of
    :class:`~ezdxf.entities.dxfentity.DXFEntity`. These entities resides in
    entity spaces like modelspace, paperspace or block.

    """

    DXFTYPE = "DXFGFX"
    DEFAULT_ATTRIBS: Dict[str, Any] = {"layer": "0"}
    DXFATTRIBS = DXFAttributes(base_class, acdb_entity)

    def load_dxf_attribs(
        self, processor: SubclassProcessor = None
    ) -> "DXFNamespace":
        """Adds subclass processing for 'AcDbEntity', requires previous base
        class processing by parent class.

        (internal API)
        """
        dxf = super().load_dxf_attribs(processor)
        if processor is None:
            return dxf
        r12 = processor.r12
        # It is valid to mix up the base class with AcDbEntity class.
        processor.append_base_class_to_acdb_entity()

        # Load proxy graphic data if requested
        if options.load_proxy_graphics:
            # length tag has group code 92 until DXF R2010
            if processor.dxfversion and processor.dxfversion < DXF2013:
                code = 92
            else:
                code = 160
            self.proxy_graphic = load_proxy_graphic(
                processor.subclasses[0 if r12 else 1],
                length_code=code,
            )
        processor.fast_load_dxfattribs(dxf, acdb_entity_group_codes, 1)
        return dxf

    def post_new_hook(self):
        """Post processing and integrity validation after entity creation
        (internal API)
        """
        if self.doc:
            if self.dxf.linetype not in self.doc.linetypes:
                raise DXFInvalidLineType(
                    f'Linetype "{self.dxf.linetype}" not defined.'
                )

    @property
    def rgb(self) -> Optional[clr.RGB]:
        """Returns RGB true color as (r, g, b) tuple or None if true_color is
        not set.
        """
        if self.dxf.hasattr("true_color"):
            return clr.int2rgb(self.dxf.get("true_color"))
        else:
            return None

    @rgb.setter
    def rgb(self, rgb: clr.RGB) -> None:
        """Set RGB true color as (r, g , b) tuple e.g. (12, 34, 56)."""
        self.dxf.set("true_color", clr.rgb2int(rgb))

    @property
    def transparency(self) -> float:
        """Get transparency as float value between 0 and 1, 0 is opaque and 1
        is 100% transparent (invisible). Transparency by block returns always 0.
        """
        if self.dxf.hasattr("transparency"):
            value = self.dxf.get("transparency")
            if validator.is_transparency(value):
                if value & TRANSPARENCY_BYBLOCK:  # just check flag state
                    return 0.0
                return clr.transparency2float(value)
        return 0.0

    @transparency.setter
    def transparency(self, transparency: float) -> None:
        """Set transparency as float value between 0 and 1, 0 is opaque and 1
        is 100% transparent (invisible).
        """
        self.dxf.set("transparency", clr.float2transparency(transparency))

    @property
    def is_transparency_by_layer(self) -> bool:
        """Returns ``True`` if entity inherits transparency from layer."""
        return not self.dxf.hasattr("transparency")

    @property
    def is_transparency_by_block(self) -> bool:
        """Returns ``True`` if entity inherits transparency from block."""
        return self.dxf.get("transparency", 0) == TRANSPARENCY_BYBLOCK

    def graphic_properties(self) -> Dict:
        """Returns the important common properties layer, color, linetype,
        lineweight, ltscale, true_color and color_name as `dxfattribs` dict.

        """
        attribs = dict()
        for key in GRAPHIC_PROPERTIES:
            if self.dxf.hasattr(key):
                attribs[key] = self.dxf.get(key)
        return attribs

    def ocs(self) -> OCS:
        """Returns object coordinate system (:ref:`ocs`) for 2D entities like
        :class:`Text` or :class:`Circle`, returns a pass-through OCS for
        entities without OCS support.

        """
        # extrusion is only defined for 2D entities like Text, Circle, ...
        if self.dxf.is_supported("extrusion"):
            extrusion = self.dxf.get("extrusion", default=(0, 0, 1))
            return OCS(extrusion)
        else:
            return OCS()

    def set_owner(self, owner: Optional[str], paperspace: int = 0) -> None:
        """Set owner attribute and paperspace flag. (internal API)"""
        self.dxf.owner = owner
        if paperspace:
            self.dxf.paperspace = paperspace
        else:
            self.dxf.discard("paperspace")

    def link_entity(self, entity: "DXFEntity") -> None:
        """Store linked or attached entities. Same API for both types of
        appended data, because entities with linked entities (POLYLINE, INSERT)
        have no attached entities and vice versa.

        (internal API)
        """
        pass

    def export_entity(self, tagwriter: "TagWriter") -> None:
        """Export entity specific data as DXF tags. (internal API)"""
        # Base class export is done by parent class.
        self.export_acdb_entity(tagwriter)
        # XDATA and embedded objects export is also done by the parent class.

    def export_acdb_entity(self, tagwriter: "TagWriter"):
        """Export subclass 'AcDbEntity' as DXF tags. (internal API)"""
        # Full control over tag order and YES, sometimes order matters
        not_r12 = tagwriter.dxfversion > DXF12
        if not_r12:
            tagwriter.write_tag2(SUBCLASS_MARKER, acdb_entity.name)

        self.dxf.export_dxf_attribs(
            tagwriter,
            [
                "paperspace",
                "layer",
                "linetype",
                "material_handle",
                "color",
                "lineweight",
                "ltscale",
                "true_color",
                "color_name",
                "transparency",
                "plotstyle_enum",
                "plotstyle_handle",
                "shadow_mode",
                "visualstyle_handle",
            ],
        )

        if self.proxy_graphic and not_r12 and options.store_proxy_graphics:
            # length tag has group code 92 until DXF R2010
            export_proxy_graphic(
                self.proxy_graphic,
                tagwriter=tagwriter,
                length_code=(92 if tagwriter.dxfversion < DXF2013 else 160),
            )

    def get_layout(self) -> Optional["BaseLayout"]:
        """Returns the owner layout or returns ``None`` if entity is not
        assigned to any layout.
        """
        if self.dxf.owner is None or self.doc is None:  # unlinked entity
            return None
        try:
            return self.doc.layouts.get_layout_by_key(self.dxf.owner)
        except DXFKeyError:
            pass
        try:
            return self.doc.blocks.get_block_layout_by_handle(self.dxf.owner)
        except DXFTableEntryError:
            return None

    def unlink_from_layout(self) -> None:
        """
        Unlink entity from associated layout. Does nothing if entity is already
        unlinked.

        It is more efficient to call the
        :meth:`~ezdxf.layouts.BaseLayout.unlink_entity` method of the associated
        layout, especially if you have to unlink more than one entity.

        """
        if not self.is_alive:
            raise TypeError("Can not unlink destroyed entity.")

        if self.doc is None:
            # no doc -> no layout
            self.dxf.owner = None
            return

        layout = self.get_layout()
        if layout:
            layout.unlink_entity(self)

    def move_to_layout(
        self, layout: "BaseLayout", source: "BaseLayout" = None
    ) -> None:
        """
        Move entity from model space or a paper space layout to another layout.
        For block layout as source, the block layout has to be specified. Moving
        between different DXF drawings is not supported.

        Args:
            layout: any layout (model space, paper space, block)
            source: provide source layout, faster for DXF R12, if entity is
                    in a block layout

        Raises:
            DXFStructureError: for moving between different DXF drawings

        """
        if source is None:
            source = self.get_layout()
            if source is None:
                raise DXFValueError("Source layout for entity not found.")
        source.move_to_layout(self, layout)

    def copy_to_layout(self, layout: "BaseLayout") -> "DXFEntity":
        """
        Copy entity to another `layout`, returns new created entity as
        :class:`DXFEntity` object. Copying between different DXF drawings is
        not supported.

        Args:
            layout: any layout (model space, paper space, block)

        Raises:
            DXFStructureError: for copying between different DXF drawings

        """
        if self.doc != layout.doc:
            raise DXFStructureError(
                "Copying between different DXF drawings is not supported."
            )

        new_entity = self.copy()
        layout.add_entity(new_entity)
        return new_entity

    def audit(self, auditor: "Auditor") -> None:
        """Audit and repair graphical DXF entities.

        .. important::

            Do not delete entities while auditing process, because this
            would alter the entity database while iterating, instead use::

                auditor.trash(entity)

            to delete invalid entities after auditing automatically.

        """
        assert self.doc is auditor.doc, "Auditor for different DXF document."
        if not self.is_alive:
            return

        super().audit(auditor)
        auditor.check_owner_exist(self)
        dxf = self.dxf
        if dxf.hasattr("layer"):
            auditor.check_for_valid_layer_name(self)
        if dxf.hasattr("linetype"):
            auditor.check_entity_linetype(self)
        if dxf.hasattr("color"):
            auditor.check_entity_color_index(self)
        if dxf.hasattr("lineweight"):
            auditor.check_entity_lineweight(self)
        if dxf.hasattr("extrusion"):
            auditor.check_extrusion_vector(self)
        if dxf.hasattr("transparency"):
            auditor.check_transparency(self)

    def transform(self, m: "Matrix44") -> "DXFGraphic":
        """Inplace transformation interface, returns `self`
        (floating interface).

        Args:
             m: 4x4 transformation matrix (:class:`ezdxf.math.Matrix44`)

        """
        raise NotImplementedError()

    def post_transform(self, m: "Matrix44") -> None:
        """Should be called if the main entity transformation was successful."""
        if self.xdata is not None:
            self.xdata.transform(m)

    @property
    def is_post_transform_required(self) -> bool:
        """Check if post transform call is required."""
        return self.xdata is not None

    def translate(self, dx: float, dy: float, dz: float) -> "DXFGraphic":
        """Translate entity inplace about `dx` in x-axis, `dy` in y-axis and
        `dz` in z-axis, returns `self` (floating interface).

        Basic implementation uses the :meth:`transform` interface, subclasses
        may have faster implementations.

        """
        return self.transform(Matrix44.translate(dx, dy, dz))

    def scale(self, sx: float, sy: float, sz: float) -> "DXFGraphic":
        """Scale entity inplace about `dx` in x-axis, `dy` in y-axis and `dz`
        in z-axis, returns `self` (floating interface).

        """
        return self.transform(Matrix44.scale(sx, sy, sz))

    def scale_uniform(self, s: float) -> "DXFGraphic":
        """Scale entity inplace uniform about `s` in x-axis, y-axis and z-axis,
        returns `self` (floating interface).

        """
        return self.transform(Matrix44.scale(s))

    def rotate_axis(self, axis: "Vertex", angle: float) -> "DXFGraphic":
        """Rotate entity inplace about vector `axis`, returns `self`
        (floating interface).

        Args:
            axis: rotation axis as tuple or :class:`Vec3`
            angle: rotation angle in radians

        """
        return self.transform(Matrix44.axis_rotate(axis, angle))

    def rotate_x(self, angle: float) -> "DXFGraphic":
        """Rotate entity inplace about x-axis, returns `self`
        (floating interface).

        Args:
            angle: rotation angle in radians

        """
        return self.transform(Matrix44.x_rotate(angle))

    def rotate_y(self, angle: float) -> "DXFGraphic":
        """Rotate entity inplace about y-axis, returns `self`
        (floating interface).

        Args:
            angle: rotation angle in radians

        """
        return self.transform(Matrix44.y_rotate(angle))

    def rotate_z(self, angle: float) -> "DXFGraphic":
        """Rotate entity inplace about z-axis, returns `self`
        (floating interface).

        Args:
            angle: rotation angle in radians

        """
        return self.transform(Matrix44.z_rotate(angle))

    def has_hyperlink(self) -> bool:
        """Returns ``True`` if entity has an attached hyperlink."""
        return bool(self.xdata) and ("PE_URL" in self.xdata)  # type: ignore

    def set_hyperlink(
        self, link: str, description: str = None, location: str = None
    ):
        """Set hyperlink of an entity."""
        xdata = [(1001, "PE_URL"), (1000, str(link))]
        if description:
            xdata.append((1002, "{"))
            xdata.append((1000, str(description)))
            if location:
                xdata.append((1000, str(location)))
            xdata.append((1002, "}"))

        self.discard_xdata("PE_URL")
        self.set_xdata("PE_URL", xdata)
        if self.doc and "PE_URL" not in self.doc.appids:
            self.doc.appids.new("PE_URL")
        return self

    def get_hyperlink(self) -> Tuple[str, str, str]:
        """Returns hyperlink, description and location."""
        link = ""
        description = ""
        location = ""
        if self.xdata and "PE_URL" in self.xdata:
            xdata = [
                tag.value
                for tag in self.get_xdata("PE_URL")
                if tag.code == 1000
            ]
            if len(xdata):
                link = xdata[0]
            if len(xdata) > 1:
                description = xdata[1]
            if len(xdata) > 2:
                location = xdata[2]
        return link, description, location

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

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

        super().remove_dependencies(other)
        # The layer attribute is preserved because layer doesn't need a layer
        # table entry, the layer attributes are reset to default attributes
        # like color is 7 and linetype is CONTINUOUS
        has_linetype = other is not None and (
            self.dxf.linetype in other.linetypes
        )
        if not has_linetype:
            self.dxf.linetype = "BYLAYER"
        self.dxf.discard("material_handle")
        self.dxf.discard("visualstyle_handle")
        self.dxf.discard("plotstyle_enum")
        self.dxf.discard("plotstyle_handle")

    def _new_compound_entity(
        self, type_: str, dxfattribs: dict
    ) -> "DXFGraphic":
        """Create and bind  new entity with same layout settings as `self`.

        Used by INSERT & POLYLINE to create appended DXF entities, don't use it
        to create new standalone entities.

        (internal API)
        """
        dxfattribs = dxfattribs or {}

        # if layer is not deliberately set, set same layer as creator entity,
        # at least VERTEX should have the same layer as the POLYGON entity.
        # Don't know if that is also important for the ATTRIB & INSERT entity.
        if "layer" not in dxfattribs:
            dxfattribs["layer"] = self.dxf.layer
        if self.doc:
            entity = factory.create_db_entry(type_, dxfattribs, self.doc)
        else:
            entity = factory.new(type_, dxfattribs)
        entity.dxf.owner = self.dxf.owner
        entity.dxf.paperspace = self.dxf.paperspace
        return entity  # type: ignore


@factory.register_entity
class SeqEnd(DXFGraphic):
    DXFTYPE = "SEQEND"


def add_entity(entity: DXFGraphic, layout: "BaseLayout") -> None:
    """Add `entity` entity to the entity database and to the given `layout`."""
    assert entity.dxf.handle is None
    assert layout is not None
    if layout.doc:
        factory.bind(entity, layout.doc)
    layout.add_entity(entity)


def replace_entity(
    source: DXFGraphic, target: DXFGraphic, layout: "BaseLayout"
) -> None:
    """Add `target` entity to the entity database and to the given `layout`
    and replace the `source` entity by the `target` entity.

    """
    assert target.dxf.handle is None
    assert layout is not None
    target.dxf.handle = source.dxf.handle
    if source in layout:
        layout.delete_entity(source)
        if layout.doc:
            factory.bind(target, layout.doc)
        layout.add_entity(target)
    else:
        source.destroy()


def is_graphic_entity(entity: DXFEntity) -> bool:
    """Returns ``True`` if the `entity` has a graphical representations and
    can reside in the model space, a paper space or a block layout,
    otherwise the entity is a table or class entry or a DXF object from the
    OBJECTS section.
    """
    if isinstance(entity, DXFGraphic):
        return True
    if isinstance(entity, DXFTagStorage) and entity.is_graphic_entity:
        return True
    return False


def get_font_name(entity: DXFEntity) -> str:
    """Returns the name of the font use by an entity.
    This function always returns a font name even if the entity does not have
    any font usage. The default font name is "txt".

    """
    font_name = "txt"
    if entity.doc and entity.dxf.hasattr("style"):
        style_name = entity.dxf.style
        style = entity.doc.styles.get(style_name)
        if style:
            font_name = style.dxf.font
    return font_name
