# Copyright (c) 2020-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Any, Optional, Union, Iterable, TYPE_CHECKING, Set
import logging
import itertools
from ezdxf import options
from ezdxf.lldxf import const
from ezdxf.lldxf.attributes import XType, DXFAttributes, DXFAttr
from ezdxf.lldxf.types import cast_value, dxftag
from ezdxf.lldxf.tags import Tags

if TYPE_CHECKING:
    from ezdxf.lldxf.extendedtags import ExtendedTags
    from ezdxf.entities import DXFEntity
    from ezdxf.lldxf.tagwriter import AbstractTagWriter


__all__ = ["DXFNamespace", "SubclassProcessor"]
logger = logging.getLogger("ezdxf")

ERR_INVALID_DXF_ATTRIB = 'Invalid DXF attribute "{}" for entity {}'
ERR_DXF_ATTRIB_NOT_EXITS = 'DXF attribute "{}" does not exist'

# supported event handler called by setting DXF attributes
# for usage, implement a method named like the dict-value, that accepts the new
# value as argument e.g.:
#   Polyline.on_layer_change(name) -> changes also layers of all vertices

SETTER_EVENTS = {
    "layer": "on_layer_change",
    "linetype": "on_linetype_change",
    "style": "on_style_change",
    "dimstyle": "on_dimstyle_change",
}
EXCLUDE_FROM_UPDATE = frozenset(["_entity", "handle", "owner"])


class DXFNamespace:
    """:class:`DXFNamespace` manages all named DXF attributes of an entity.

    The DXFNamespace.__dict__ is used as DXF attribute storage, therefore only
    valid Python names can be used as attrib name.

    The namespace can only contain immutable objects: string, int, float, bool,
    Vec3. Because of the immutability, copy and deepcopy are the same.

    (internal class)
    """

    def __init__(
        self,
        processor: Optional[SubclassProcessor] = None,
        entity: Optional[DXFEntity] = None,
    ):
        if processor:
            base_class = processor.base_class
            handle_code = 105 if base_class[0].value == "DIMSTYLE" else 5
            # CLASS entities have no handle.
            # TABLE entities have no handle if loaded from a DXF R12 file.
            # Owner tag is None if loaded from a DXF R12 file
            handle = None
            owner = None
            for tag in base_class:
                group_code = tag.code
                if group_code == handle_code:
                    handle = tag.value
                    if owner:
                        break
                elif group_code == 330:
                    owner = tag.value
                    if handle:
                        break
            self.rewire(entity, handle, owner)
        else:
            self.reset_handles()
            self.rewire(entity)

    def copy(self, entity: DXFEntity):
        namespace = self.__class__()
        for k, v in self.__dict__.items():
            namespace.__dict__[k] = v
        namespace.rewire(entity)
        return namespace

    def __deepcopy__(self, memodict: Optional[dict] = None):
        return self.copy(self._entity)

    def reset_handles(self):
        """Reset handle and owner to None."""
        self.__dict__["handle"] = None
        self.__dict__["owner"] = None

    def rewire(
        self,
        entity: Optional[DXFEntity],
        handle: Optional[str] = None,
        owner: Optional[str] = None,
    ) -> None:
        """Rewire DXF namespace with parent entity

        Args:
            entity: new associated entity
            handle: new handle or None
            owner:  new entity owner handle or None

        """
        # bypass __setattr__()
        self.__dict__["_entity"] = entity
        if handle is not None:
            self.__dict__["handle"] = handle
        if owner is not None:
            self.__dict__["owner"] = owner

    def __getattr__(self, key: str) -> Any:
        """Called if DXF attribute `key` does not exist, returns the DXF
        default value or ``None``.

        Raises:
            DXFAttributeError: attribute `key` is not supported

        """
        attrib_def: Optional[DXFAttr] = self.dxfattribs.get(key)
        if attrib_def:
            if attrib_def.xtype == XType.callback:
                return attrib_def.get_callback_value(self._entity)
            else:
                return attrib_def.default
        else:
            raise const.DXFAttributeError(
                ERR_INVALID_DXF_ATTRIB.format(key, self.dxftype)
            )

    def __setattr__(self, key: str, value: Any) -> None:
        """Set DXF attribute `key` to `value`.

        Raises:
            DXFAttributeError: attribute `key` is not supported

        """

        def entity() -> str:
            # DXFNamespace is maybe not assigned to the entity yet:
            handle = self.get("handle")
            _entity = self._entity
            if _entity:
                return _entity.dxftype() + f"(#{handle})"
            else:
                return f"#{handle}"

        def check(value):
            value = cast_value(attrib_def.code, value)
            if not attrib_def.is_valid_value(value):
                if attrib_def.fixer:
                    value = attrib_def.fixer(value)
                    logger.debug(
                        f'Fixed invalid attribute "{key}" in entity'
                        f' {entity()} to "{str(value)}".'
                    )
                else:
                    raise const.DXFValueError(
                        f'Invalid value {str(value)} for attribute "{key}" in '
                        f"entity {entity()}."
                    )
            return value

        attrib_def: Optional[DXFAttr] = self.dxfattribs.get(key)
        if attrib_def:
            if attrib_def.xtype == XType.callback:
                attrib_def.set_callback_value(self._entity, value)
            else:
                self.__dict__[key] = check(value)
        else:
            raise const.DXFAttributeError(
                ERR_INVALID_DXF_ATTRIB.format(key, self.dxftype)
            )

        if key in SETTER_EVENTS:
            handler = getattr(self._entity, SETTER_EVENTS[key], None)
            if handler:
                handler(value)

    def __delattr__(self, key: str) -> None:
        """Delete DXF attribute `key`.

        Raises:
            DXFAttributeError: attribute `key` does not exist

        """
        if self.hasattr(key):
            del self.__dict__[key]
        else:
            raise const.DXFAttributeError(ERR_DXF_ATTRIB_NOT_EXITS.format(key))

    def get(self, key: str, default: Any = None) -> Any:
        """Returns value of DXF attribute `key` or the given `default` value
        not DXF default value for unset attributes.

        Raises:
            DXFAttributeError: attribute `key` is not supported

        """
        # callback values should not exist as attribute in __dict__
        if self.hasattr(key):
            # do not return the DXF default value
            return self.__dict__[key]
        attrib_def: Optional["DXFAttr"] = self.dxfattribs.get(key)
        if attrib_def:
            if attrib_def.xtype == XType.callback:
                return attrib_def.get_callback_value(self._entity)
            else:
                return default  # return give default
        else:
            raise const.DXFAttributeError(
                ERR_INVALID_DXF_ATTRIB.format(key, self.dxftype)
            )

    def get_default(self, key: str) -> Any:
        """Returns DXF default value for unset DXF attribute `key`."""
        value = self.get(key, None)
        return self.dxf_default_value(key) if value is None else value

    def set(self, key: str, value: Any) -> None:
        """Set DXF attribute `key` to `value`.

        Raises:
            DXFAttributeError: attribute `key` is not supported

        """
        self.__setattr__(key, value)

    def unprotected_set(self, key: str, value: Any) -> None:
        """Set DXF attribute `key` to `value` without any validity checks.

        Used for fast attribute setting without validity checks at loading time.

        (internal API)
        """
        self.__dict__[key] = value

    def all_existing_dxf_attribs(self) -> dict:
        """Returns all existing DXF attributes, except DXFEntity back-link."""
        attribs = dict(self.__dict__)
        del attribs["_entity"]
        return attribs

    def update(
        self,
        dxfattribs: dict[str, Any],
        *,
        exclude: Optional[Set[str]] = None,
        ignore_errors=False,
    ) -> None:
        """Update DXF namespace attributes from a dict."""
        if exclude is None:
            exclude = EXCLUDE_FROM_UPDATE  # type: ignore
        else:  # always exclude "_entity" back-link
            exclude = {"_entity"} | exclude

        set_attribute = self.__setattr__
        for k, v in dxfattribs.items():
            if k not in exclude:  # type: ignore
                try:
                    set_attribute(k, v)
                except (AttributeError, ValueError):
                    if not ignore_errors:
                        raise

    def discard(self, key: str) -> None:
        """Delete DXF attribute `key` silently without any exception."""
        try:
            del self.__dict__[key]
        except KeyError:
            pass

    def is_supported(self, key: str) -> bool:
        """Returns True if DXF attribute `key` is supported else False.
        Does not grant that attribute `key` really exists and does not
        check if the actual DXF version of the document supports this
        attribute, unsupported attributes will be ignored at export.

        """
        return key in self.dxfattribs

    def hasattr(self, key: str) -> bool:
        """Returns True if attribute `key` really exists else False."""
        return key in self.__dict__

    @property
    def dxftype(self) -> str:
        """Returns the DXF entity type."""
        return self._entity.DXFTYPE

    @property
    def dxfattribs(self) -> DXFAttributes:
        """Returns the DXF attribute definition."""
        return self._entity.DXFATTRIBS

    def dxf_default_value(self, key: str) -> Any:
        """Returns the default value as defined in the DXF standard."""
        attrib: Optional[DXFAttr] = self.dxfattribs.get(key)
        if attrib:
            return attrib.default
        else:
            return None

    def export_dxf_attribs(
        self, tagwriter: AbstractTagWriter, attribs: Union[str, Iterable]
    ) -> None:
        """Exports DXF attribute `name` by `tagwriter`. Non-optional attributes
        are forced and optional tags are only written if different to default
        value. DXF version check is always on: does not export DXF attribs
        which are not supported by tagwriter.dxfversion.

        Args:
            tagwriter: tag writer object
            attribs: DXF attribute name as string or an iterable of names

        """
        if isinstance(attribs, str):
            self._export_dxf_attribute_optional(tagwriter, attribs)
        else:
            for name in attribs:
                self._export_dxf_attribute_optional(tagwriter, name)

    def _export_dxf_attribute_optional(
        self, tagwriter: AbstractTagWriter, name: str
    ) -> None:
        """Exports DXF attribute `name` by `tagwriter`. Optional tags are only
        written if different to default value.

        Args:
            tagwriter: tag writer object
            name: DXF attribute name

        """
        export_dxf_version = tagwriter.dxfversion
        not_force_optional = not tagwriter.force_optional
        attrib: Optional[DXFAttr] = self.dxfattribs.get(name)

        if attrib:
            optional = attrib.optional
            default = attrib.default
            value = self.get(name, None)
            # Force default value e.g. layer
            if value is None and not optional:
                # Default value could be None
                value = default

                # Do not export None values
            if (value is not None) and (
                export_dxf_version >= attrib.dxfversion
            ):
                # Do not write explicit optional attribs if equal to default
                # value
                if (
                    optional
                    and not_force_optional
                    and default is not None
                    and default == value
                ):
                    return
                    # Just export x, y for 2D points, if value is a 3D point
                if attrib.xtype == XType.point2d and len(value) > 2:
                    try:  # Vec3
                        value = (value.x, value.y)
                    except AttributeError:
                        value = value[:2]

                if isinstance(value, str):
                    assert "\n" not in value, "line break '\\n' not allowed"
                    assert "\r" not in value, "line break '\\r' not allowed"
                tag = dxftag(attrib.code, value)
                tagwriter.write_tag(tag)
        else:
            raise const.DXFAttributeError(
                ERR_INVALID_DXF_ATTRIB.format(name, self.dxftype)
            )


BASE_CLASS_CODES = {0, 5, 102, 330}


class SubclassProcessor:
    """Helper class for loading tags into entities. (internal class)"""

    def __init__(self, tags: ExtendedTags, dxfversion: Optional[str] = None):
        if len(tags.subclasses) == 0:
            raise ValueError("Invalid tags.")
        self.subclasses: list[Tags] = list(tags.subclasses)  # copy subclasses
        self.embedded_objects: list[Tags] = tags.embedded_objects or []
        self.dxfversion: Optional[str] = dxfversion
        # DXF R12 and prior have no subclass marker system, all tags of an
        # entity in one flat list.
        # Later DXF versions have at least 2 subclasses base_class and
        # AcDbEntity.
        # Exception: CLASS has also only one subclass and no subclass marker,
        # handled as DXF R12 entity
        self.r12: bool = (dxfversion == const.DXF12) or (
            len(self.subclasses) == 1
        )
        self.name: str = tags.dxftype()
        self.handle: str
        try:
            self.handle = tags.get_handle()
        except const.DXFValueError:
            self.handle = "<?>"

    @property
    def base_class(self):
        return self.subclasses[0]

    def log_unprocessed_tags(
        self,
        unprocessed_tags: Iterable,
        subclass="<?>",
        handle: Optional[str] = None,
    ) -> None:
        if options.log_unprocessed_tags:
            for tag in unprocessed_tags:
                entity = ""
                if handle:
                    entity = f" in entity #{handle}"
                logger.info(
                    f"ignored {repr(tag)} in subclass {subclass}" + entity
                )

    def find_subclass(self, name: str) -> Optional[Tags]:
        for subclass in self.subclasses:
            if len(subclass) and subclass[0].value == name:
                return subclass
        return None

    def subclass_by_index(self, index: int) -> Optional[Tags]:
        try:
            return self.subclasses[index]
        except IndexError:
            return None

    def detect_implementation_version(
        self, subclass_index: int, group_code: int, default: int
    ) -> int:
        subclass = self.subclass_by_index(subclass_index)
        if subclass and len(subclass) > 1:
            # the version tag has to be the 2nd tag after the subclass marker
            tag = subclass[1]
            if tag.code == group_code:
                return tag.value
        return default

    # TODO: rename to complex_dxfattribs_loader()
    def fast_load_dxfattribs(
        self,
        dxf: DXFNamespace,
        group_code_mapping: dict[int, Union[str, list]],
        subclass: Union[int, str, Tags],
        *,
        recover=False,
        log=True,
    ) -> Tags:
        """Load DXF attributes into the DXF namespace and returns the
        unprocessed tags without leading subclass marker(100, AcDb...).
        Bypasses the DXF attribute validity checks.

        Args:
            dxf: entity DXF namespace
            group_code_mapping: group code to DXF attribute name mapping,
                callback attributes have to be marked with a leading "*"
            subclass: subclass by index, by name or as Tags()
            recover: recover graphic attributes
            log: enable/disable logging of unprocessed tags

        """
        if self.r12:
            tags = self.subclasses[0]
        else:
            if isinstance(subclass, int):
                tags = self.subclass_by_index(subclass)  # type: ignore
            elif isinstance(subclass, str):
                tags = self.find_subclass(subclass)  # type: ignore
            else:
                tags = subclass

        unprocessed_tags = Tags()
        if tags is None or len(tags) == 0:
            return unprocessed_tags

        processed_names: set[str] = set()
        # Localize attributes:
        get_attrib_name = group_code_mapping.get
        append_unprocessed_tag = unprocessed_tags.append
        unprotected_set_attrib = dxf.unprotected_set
        mark_attrib_as_processed = processed_names.add

        # Ignore (100, "AcDb...") or (0, "ENTITY") tag in case of DXF R12
        start = 1 if tags[0].code in (0, 100) else 0
        for tag in tags[start:]:
            name = get_attrib_name(tag.code)
            if isinstance(name, list):  # process group code duplicates:
                names = name
                # If all existing attrib names are used, treat this tag
                # like an unprocessed tag.
                name = None
                # The attribute names are processed in the order of their
                # definition:
                for name_ in names:
                    if name_ not in processed_names:
                        name = name_
                        mark_attrib_as_processed(name_)
                        break
            if name:
                # Ignore callback attributes and group codes explicit marked
                # as "*IGNORE":
                if name[0] != "*":
                    unprotected_set_attrib(
                        name, cast_value(tag.code, tag.value)  # type: ignore
                    )
            else:
                append_unprocessed_tag(tag)

        if self.r12:
            # R12 has always unprocessed tags, because there are all tags in one
            # subclass and one subclass definition never covers all tags e.g.
            # handle is processed in DXFEntity, so it is an unprocessed tag in
            # AcDbEntity.
            return unprocessed_tags

        # Only DXF R13+
        if recover and len(unprocessed_tags):
            # TODO: maybe obsolete if simple_dxfattribs_loader() is used for
            #  most old DXF R12 entities
            unprocessed_tags = recover_graphic_attributes(unprocessed_tags, dxf)
        if len(unprocessed_tags) and log:
            # First tag is the subclass specifier (100, "AcDb...")
            name = tags[0].value
            self.log_unprocessed_tags(
                tags, subclass=name, handle=dxf.get("handle")
            )
        return unprocessed_tags

    def append_base_class_to_acdb_entity(self) -> None:
        """It is valid to mix up the base class with AcDbEntity class.
        This method appends all none base class group codes to the
        AcDbEntity class.
        """
        # This is only needed for DXFEntity, so applying this method
        # automatically to all entities is waste of runtime
        # -> DXFGraphic.load_dxf_attribs()
        # TODO: maybe obsolete if simple_dxfattribs_loader() is used for
        #  most old DXF R12 entities
        if self.r12:
            return

        acdb_entity_tags = self.subclasses[1]
        if acdb_entity_tags[0] == (100, "AcDbEntity"):
            acdb_entity_tags.extend(
                tag
                for tag in self.subclasses[0]
                if tag.code not in BASE_CLASS_CODES
            )

    def simple_dxfattribs_loader(
        self, dxf: DXFNamespace, group_code_mapping: dict[int, str]
    ) -> None:
        # tested in test suite 201 for the POINT entity
        """Load DXF attributes from all subclasses into the DXF namespace.

        Can not handle same group codes in different subclasses, does not remove
        processed tags or log unprocessed tags and bypasses the DXF attribute
        validity checks.

        This method ignores the subclass structure and can load data from
        very malformed DXF files, like such in issue #604.
        This method works only for very simple DXF entities with unique group
        codes in all subclasses, the old DXF R12 entities:

            - POINT
            - LINE
            - CIRCLE
            - ARC
            - INSERT
            - SHAPE
            - SOLID/TRACE/3DFACE
            - TEXT (ATTRIB/ATTDEF bypasses TEXT loader)
            - BLOCK/ENDBLK
            - POLYLINE/VERTEX/SEQEND
            - DIMENSION and subclasses
            - all table entries: LAYER, LTYPE, ...

        And the newer DXF entities:

            - ELLIPSE
            - RAY/XLINE

        The recover mode for graphical attributes is automatically included.
        Logging of unprocessed tags is not possible but also not required for
        this simple and well known entities.

        Args:
            dxf: entity DXF namespace
            group_code_mapping: group code name mapping for all DXF attributes
                from all subclasses, callback attributes have to be marked with
                a leading "*"

        """
        tags = itertools.chain.from_iterable(self.subclasses)
        get_attrib_name = group_code_mapping.get
        unprotected_set_attrib = dxf.unprotected_set
        for tag in tags:
            name = get_attrib_name(tag.code)
            if isinstance(name, str) and not name.startswith("*"):
                unprotected_set_attrib(
                    name, cast_value(tag.code, tag.value)
                )


GRAPHIC_ATTRIBUTES_TO_RECOVER = {
    8: "layer",
    6: "linetype",
    62: "color",
    67: "paperspace",
    370: "lineweight",
    48: "ltscale",
    60: "invisible",
    420: "true_color",
    430: "color_name",
    440: "transparency",
    284: "shadow_mode",
    347: "material_handle",
    348: "visualstyle_handle",
    380: "plotstyle_enum",
    390: "plotstyle_handle",
}


# TODO: maybe obsolete if simple_dxfattribs_loader() is used for
#  most old DXF R12 entities
def recover_graphic_attributes(tags: Tags, dxf: DXFNamespace) -> Tags:
    unprocessed_tags = Tags()
    for tag in tags:
        attrib_name = GRAPHIC_ATTRIBUTES_TO_RECOVER.get(tag.code)
        # Don't know if the unprocessed tag is really a misplaced tag,
        # so check if the attribute already exist!
        if attrib_name and not dxf.hasattr(attrib_name):
            dxf.set(attrib_name, tag.value)
        else:
            unprocessed_tags.append(tag)
    return unprocessed_tags
