# Copyright (c) 2019-2024, Manfred Moitzi
# License: MIT-License
from __future__ import annotations
from typing import TYPE_CHECKING, Union, Optional
from typing_extensions import Self
import logging
from ezdxf.lldxf import validator
from ezdxf.lldxf.const import (
    SUBCLASS_MARKER,
    DXFKeyError,
    DXFValueError,
    DXFTypeError,
    DXFStructureError,
)
from ezdxf.lldxf.attributes import (
    DXFAttr,
    DXFAttributes,
    DefSubclass,
    RETURN_DEFAULT,
    group_code_mapping,
)
from ezdxf.lldxf.types import is_valid_handle
from ezdxf.audit import AuditError
from ezdxf.entities import factory, DXFGraphic
from .dxfentity import base_class, SubclassProcessor, DXFEntity
from .dxfobj import DXFObject
from .copy import default_copy, CopyNotSupported

if TYPE_CHECKING:
    from ezdxf.entities import DXFNamespace, XRecord
    from ezdxf.lldxf.tagwriter import AbstractTagWriter
    from ezdxf.document import Drawing
    from ezdxf.audit import Auditor
    from ezdxf import xref

__all__ = ["Dictionary", "DictionaryWithDefault", "DictionaryVar"]
logger = logging.getLogger("ezdxf")

acdb_dictionary = DefSubclass(
    "AcDbDictionary",
    {
        # If hard_owned is set to 1 the entries are owned by the DICTIONARY.
        # The 1 state seems to be the default value, but is not documented by
        # the DXF reference.
        # BricsCAD creates the root DICTIONARY and the top level DICTIONARY entries
        # without group code 280 tags, and they are all definitely hard owner of their
        # entries, because their entries have the DICTIONARY handle as owner handle.
        "hard_owned": DXFAttr(
            280,
            default=1,
            optional=True,
            validator=validator.is_integer_bool,
            fixer=RETURN_DEFAULT,
        ),
        # Duplicate record cloning flag (determines how to merge duplicate entries):
        # 0 = not applicable
        # 1 = keep existing
        # 2 = use clone
        # 3 = <xref>$0$<name>
        # 4 = $0$<name>
        # 5 = Unmangle name
        "cloning": DXFAttr(
            281,
            default=1,
            validator=validator.is_in_integer_range(0, 6),
            fixer=RETURN_DEFAULT,
        ),
        # 3: entry name
        # 350: entry handle, some DICTIONARY objects have 360 as handle group code,
        # this is accepted by AutoCAD but not documented by the DXF reference!
        # ezdxf replaces group code 360 by 350.
        # - group code 350 is a soft-owner handle
        # - group code 360 is a hard-owner handle
    },
)
acdb_dictionary_group_codes = group_code_mapping(acdb_dictionary)
KEY_CODE = 3
VALUE_CODE = 350
# Some DICTIONARY use group code 360:
SEARCH_CODES = (VALUE_CODE, 360)


@factory.register_entity
class Dictionary(DXFObject):
    """AutoCAD maintains items such as mline styles and group definitions as
    objects in dictionaries. Other applications are free to create and use
    their own dictionaries as they see fit. The prefix "ACAD_" is reserved
    for use by AutoCAD applications.

    Dictionary entries are (key, DXFEntity) pairs. DXFEntity could be a string,
    because at loading time not all objects are already stored in the EntityDB,
    and have to be acquired later.

    """

    DXFTYPE = "DICTIONARY"
    DXFATTRIBS = DXFAttributes(base_class, acdb_dictionary)

    def __init__(self) -> None:
        super().__init__()
        self._data: dict[str, Union[str, DXFObject]] = dict()
        self._value_code = VALUE_CODE

    def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
        """Copy hard owned entities but do not store the copies in the entity
        database, this is a second step (factory.bind), this is just real copying.
        """
        assert isinstance(entity, Dictionary)
        entity._value_code = self._value_code
        if self.dxf.hard_owned:
            # Reactors are removed from the cloned DXF objects.
            data: dict[str, DXFEntity] = dict()
            for key, ent in self.items():
                # ignore strings and None - these entities do not exist
                # in the entity database
                if isinstance(ent, DXFEntity):
                    try:  # todo: follow CopyStrategy.ignore_copy_errors_in_linked entities
                        data[key] = ent.copy(copy_strategy=copy_strategy)
                    except CopyNotSupported:
                        if copy_strategy.settings.ignore_copy_errors_in_linked_entities:
                            logger.warning(
                                f"copy process ignored {str(ent)} - this may cause problems in AutoCAD"
                            )
                        else:
                            raise
            entity._data = data  # type: ignore
        else:
            entity._data = dict(self._data)

    def get_handle_mapping(self, clone: Dictionary) -> dict[str, str]:
        """Returns handle mapping for in-object copies."""
        handle_mapping: dict[str, str] = dict()
        if not self.is_hard_owner:
            return handle_mapping

        for key, entity in self.items():
            if not isinstance(entity, DXFEntity):
                continue
            copied_entry = clone.get(key)
            if copied_entry:
                handle_mapping[entity.dxf.handle] = copied_entry.dxf.handle
        return handle_mapping

    def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
        """Translate resources from self to the copied entity."""
        assert isinstance(clone, Dictionary)
        super().map_resources(clone, mapping)
        if self.is_hard_owner:
            return
        data = dict()
        for key, entity in self.items():
            if not isinstance(entity, DXFEntity):
                continue
            entity_copy = mapping.get_reference_of_copy(entity.dxf.handle)
            if entity_copy:
                data[key] = entity
        clone._data = data  # type: ignore

    def del_source_of_copy(self) -> None:
        super().del_source_of_copy()
        for _, entity in self.items():
            if isinstance(entity, DXFEntity) and entity.is_alive:
                entity.del_source_of_copy()

    def post_bind_hook(self) -> None:
        """Called by binding a new or copied dictionary to the document,
        bind hard owned sub-entities to the same document and add them to the
        objects section.
        """
        if not self.dxf.hard_owned:
            return

        # copied or new dictionary:
        doc = self.doc
        assert doc is not None
        object_section = doc.objects
        owner_handle = self.dxf.handle
        for _, entity in self.items():
            entity.dxf.owner = owner_handle
            factory.bind(entity, doc)
            # For a correct DXF export add entities to the objects section:
            object_section.add_object(entity)

    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_dictionary_group_codes, 1, log=False
            )
            self.load_dict(tags)
        return dxf

    def load_dict(self, tags):
        entry_handle = None
        dict_key = None
        value_code = VALUE_CODE
        for code, value in tags:
            if code in SEARCH_CODES:
                # First store handles, because at this point, NOT all objects
                # are stored in the EntityDB, at first access convert the handle
                # to a DXFEntity object.
                value_code = code
                entry_handle = value
            elif code == KEY_CODE:
                dict_key = value
            if dict_key and entry_handle:
                # Store entity as handle string:
                self._data[dict_key] = entry_handle
                entry_handle = None
                dict_key = None
        # Use same value code as loaded:
        self._value_code = value_code

    def post_load_hook(self, doc: Drawing) -> None:
        super().post_load_hook(doc)
        db = doc.entitydb

        def items():
            for key, handle in self.items():
                entity = db.get(handle)
                if entity is not None and entity.is_alive:
                    yield key, entity

        if len(self):
            for k, v in list(items()):
                self.__setitem__(k, v)

    def export_entity(self, tagwriter: AbstractTagWriter) -> None:
        """Export entity specific data as DXF tags."""
        super().export_entity(tagwriter)
        tagwriter.write_tag2(SUBCLASS_MARKER, acdb_dictionary.name)
        self.dxf.export_dxf_attribs(tagwriter, ["hard_owned", "cloning"])
        self.export_dict(tagwriter)

    def export_dict(self, tagwriter: AbstractTagWriter):
        # key: dict key string
        # value: DXFEntity or handle as string
        # Ignore invalid handles at export, because removing can create an empty
        # dictionary, which is more a problem for AutoCAD than invalid handles,
        # and removing the whole dictionary is maybe also a problem.
        for key, value in self._data.items():
            tagwriter.write_tag2(KEY_CODE, key)
            # Value can be a handle string or a DXFEntity object:
            if isinstance(value, DXFEntity):
                if value.is_alive:
                    value = value.dxf.handle
                else:
                    logger.debug(
                        f'Key "{key}" points to a destroyed entity '
                        f'in {str(self)}, target replaced by "0" handle.'
                    )
                    value = "0"
            # Use same value code as loaded:
            tagwriter.write_tag2(self._value_code, value)

    @property
    def is_hard_owner(self) -> bool:
        """Returns ``True`` if the dictionary is hard owner of entities.
        Hard owned entities will be destroyed by deleting the dictionary.
        """
        return bool(self.dxf.hard_owned)

    def keys(self):
        """Returns a :class:`KeysView` of all dictionary keys."""
        return self._data.keys()

    def items(self):
        """Returns an :class:`ItemsView` for all dictionary entries as
        (key, entity) pairs. An entity can be a handle string if the entity
        does not exist.
        """
        for key in self.keys():
            yield key, self.get(key)  # maybe handle -> DXFEntity

    def __getitem__(self, key: str) -> DXFEntity:
        """Return self[`key`].

        The returned value can be a handle string if the entity does not exist.

        Raises:
            DXFKeyError: `key` does not exist

        """
        if key in self._data:
            return self._data[key]  # type: ignore
        else:
            raise DXFKeyError(key)

    def __setitem__(self, key: str, entity: DXFObject) -> None:
        """Set self[`key`] = `entity`.

        Only DXF objects stored in the OBJECTS section are allowed as content
        of :class:`Dictionary` objects. DXF entities stored in layouts are not
        allowed.

        Raises:
            DXFTypeError: invalid DXF type

        """
        return self.add(key, entity)

    def __delitem__(self, key: str) -> None:
        """Delete self[`key`].

        Raises:
            DXFKeyError: `key` does not exist

        """
        return self.remove(key)

    def __contains__(self, key: str) -> bool:
        """Returns `key` ``in`` self."""
        return key in self._data

    def __len__(self) -> int:
        """Returns count of dictionary entries."""
        return len(self._data)

    count = __len__

    def get(self, key: str, default: Optional[DXFObject] = None) -> Optional[DXFObject]:
        """Returns the :class:`DXFEntity` for `key`, if `key` exist else
        `default`. An entity can be a handle string if the entity
        does not exist.

        """
        return self._data.get(key, default)  # type: ignore

    def find_key(self, entity: DXFEntity) -> str:
        """Returns the DICTIONARY key string for `entity` or an empty string if not
        found.
        """
        for key, entry in self._data.items():
            if entry is entity:
                return key
        return ""

    def add(self, key: str, entity: DXFObject) -> None:
        """Add entry (key, value).

        If the DICTIONARY is hard owner of its entries, the :meth:`add` does NOT take
        ownership of the entity automatically.

        Raises:
            DXFValueError: invalid entity handle
            DXFTypeError: invalid DXF type

        """
        if isinstance(entity, str):
            if not is_valid_handle(entity):
                raise DXFValueError(f"Invalid entity handle #{entity} for key {key}")
        elif isinstance(entity, DXFGraphic):
            if self.doc is not None and self.doc.is_loading:  # type: ignore
                # AutoCAD add-ons can store graphical entities in DICTIONARIES
                # in the OBJECTS section and AutoCAD does not complain - so just
                # preserve them!
                # Example "ZJMC-288.dxf" in issue #585, add-on: "acdgnlsdraw.crx"?
                logger.warning(f"Invalid entity {str(entity)} in {str(self)}")
            else:
                # Do not allow ezdxf users to add graphical entities to a
                # DICTIONARY object!
                raise DXFTypeError(f"Graphic entities not allowed: {entity.dxftype()}")
        self._data[key] = entity

    def take_ownership(self, key: str, entity: DXFObject):
        """Add entry (key, value) and take ownership."""
        self.add(key, entity)
        entity.dxf.owner = self.dxf.handle

    def remove(self, key: str) -> None:
        """Delete entry `key`. Raises :class:`DXFKeyError`, if `key` does not
        exist. Destroys hard owned DXF entities.

        """
        data = self._data
        if key not in data:
            raise DXFKeyError(key)

        if self.is_hard_owner:
            assert self.doc is not None
            entity = self.__getitem__(key)
            # Presumption: hard owned DXF objects always reside in the OBJECTS
            # section.
            self.doc.objects.delete_entity(entity)  # type: ignore
        del data[key]

    def discard(self, key: str) -> None:
        """Delete entry `key` if exists. Does not raise an exception if `key`
        doesn't exist and does not destroy hard owned DXF entities.

        """
        try:
            del self._data[key]
        except KeyError:
            pass

    def clear(self) -> None:
        """Delete all entries from the dictionary and destroys hard owned
        DXF entities.
        """
        if self.is_hard_owner:
            self._delete_hard_owned_entries()
        self._data.clear()

    def _delete_hard_owned_entries(self) -> None:
        # Presumption: hard owned DXF objects always reside in the OBJECTS section
        objects = self.doc.objects  # type: ignore
        for key, entity in self.items():
            if isinstance(entity, DXFEntity):
                objects.delete_entity(entity)  # type: ignore

    def add_new_dict(self, key: str, hard_owned: bool = False) -> Dictionary:
        """Create a new sub-dictionary of type :class:`Dictionary`.

        Args:
            key: name of the sub-dictionary
            hard_owned: entries of the new dictionary are hard owned

        """
        dxf_dict = self.doc.objects.add_dictionary(  # type: ignore
            owner=self.dxf.handle, hard_owned=hard_owned
        )
        self.add(key, dxf_dict)
        return dxf_dict

    def add_dict_var(self, key: str, value: str) -> DictionaryVar:
        """Add a new :class:`DictionaryVar`.

        Args:
             key: entry name as string
             value: entry value as string

        """
        new_var = self.doc.objects.add_dictionary_var(  # type: ignore
            owner=self.dxf.handle, value=value
        )
        self.add(key, new_var)
        return new_var

    def add_xrecord(self, key: str) -> XRecord:
        """Add a new :class:`XRecord`.

        Args:
             key: entry name as string

        """
        new_xrecord = self.doc.objects.add_xrecord(  # type: ignore
            owner=self.dxf.handle,
        )
        self.add(key, new_xrecord)
        return new_xrecord

    def set_or_add_dict_var(self, key: str, value: str) -> DictionaryVar:
        """Set or add new :class:`DictionaryVar`.

        Args:
             key: entry name as string
             value: entry value as string

        """
        if key not in self:
            dict_var = self.doc.objects.add_dictionary_var(  # type: ignore
                owner=self.dxf.handle, value=value
            )
            self.add(key, dict_var)
        else:
            dict_var = self.get(key)
            dict_var.dxf.value = str(value)  # type: ignore
        return dict_var

    def link_dxf_object(self, name: str, obj: DXFObject) -> None:
        """Add `obj` and set owner of `obj` to this dictionary.

        Graphical DXF entities have to reside in a layout and therefore can not
        be owned by a :class:`Dictionary`.

        Raises:
            DXFTypeError: `obj` has invalid DXF type

        """
        if not isinstance(obj, DXFObject):
            raise DXFTypeError(f"invalid DXF type: {obj.dxftype()}")
        self.add(name, obj)
        obj.dxf.owner = self.dxf.handle

    def get_required_dict(self, key: str, hard_owned=False) -> Dictionary:
        """Get entry `key` or create a new :class:`Dictionary`,
        if `Key` not exist.
        """
        dxf_dict = self.get(key)
        if dxf_dict is None:
            dxf_dict = self.add_new_dict(key, hard_owned=hard_owned)
        elif not isinstance(dxf_dict, Dictionary):
            raise DXFStructureError(
                f"expected a DICTIONARY entity, got {str(dxf_dict)} for key: {key}"
            )
        return dxf_dict

    def audit(self, auditor: Auditor) -> None:
        if not self.is_alive:
            return
        super().audit(auditor)
        self._remove_keys_to_missing_entities(auditor)

    def _remove_keys_to_missing_entities(self, auditor: Auditor):
        trash: list[str] = []
        append = trash.append
        db = auditor.entitydb
        for key, entry in self._data.items():
            if isinstance(entry, str):
                if entry not in db:
                    append(key)
            elif entry.is_alive:
                if entry.dxf.handle not in db:
                    append(key)
                    continue
            else:  # entity is destroyed, remove key
                append(key)
        for key in trash:
            del self._data[key]
            auditor.fixed_error(
                code=AuditError.INVALID_DICTIONARY_ENTRY,
                message=f'Removed entry "{key}" with invalid handle in {str(self)}',
                dxf_entity=self,
                data=key,
            )

    def destroy(self) -> None:
        if not self.is_alive:
            return

        if self.is_hard_owner:
            self._delete_hard_owned_entries()
        super().destroy()


acdb_dict_with_default = DefSubclass(
    "AcDbDictionaryWithDefault",
    {
        "default": DXFAttr(340),
    },
)
acdb_dict_with_default_group_codes = group_code_mapping(acdb_dict_with_default)


@factory.register_entity
class DictionaryWithDefault(Dictionary):
    DXFTYPE = "ACDBDICTIONARYWDFLT"
    DXFATTRIBS = DXFAttributes(base_class, acdb_dictionary, acdb_dict_with_default)

    def __init__(self) -> None:
        super().__init__()
        self._default: Optional[DXFObject] = None

    def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
        super().copy_data(entity, copy_strategy=copy_strategy)
        assert isinstance(entity, DictionaryWithDefault)
        entity._default = self._default

    def post_load_hook(self, doc: Drawing) -> None:
        # Set _default to None if default object not exist - audit() replaces
        # a not existing default object by a placeholder object.
        # AutoCAD ignores not existing default objects!
        self._default = doc.entitydb.get(self.dxf.default)  # type: ignore
        super().post_load_hook(doc)

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

    def export_entity(self, tagwriter: AbstractTagWriter) -> None:
        super().export_entity(tagwriter)
        tagwriter.write_tag2(SUBCLASS_MARKER, acdb_dict_with_default.name)
        self.dxf.export_dxf_attribs(tagwriter, "default")

    def __getitem__(self, key: str):
        return self.get(key)

    def get(self, key: str, default: Optional[DXFObject] = None) -> Optional[DXFObject]:
        # `default` argument is ignored, exist only for API compatibility,
        """Returns :class:`DXFEntity` for `key` or the predefined dictionary
        wide :attr:`dxf.default` entity if `key` does not exist or ``None``
        if default value also not exist.

        """
        return super().get(key, default=self._default)

    def set_default(self, default: DXFObject) -> None:
        """Set dictionary wide default entry.

        Args:
            default: default entry as :class:`DXFEntity`

        """
        self._default = default
        self.dxf.default = self._default.dxf.handle

    def audit(self, auditor: Auditor) -> None:
        def create_missing_default_object():
            placeholder = self.doc.objects.add_placeholder(owner=self.dxf.handle)
            self.set_default(placeholder)
            auditor.fixed_error(
                code=AuditError.CREATED_MISSING_OBJECT,
                message=f"Created missing default object in {str(self)}.",
            )

        if self._default is None or not self._default.is_alive:
            if auditor.entitydb.locked:
                auditor.add_post_audit_job(create_missing_default_object)
            else:
                create_missing_default_object()
        super().audit(auditor)


acdb_dict_var = DefSubclass(
    "DictionaryVariables",
    {
        "schema": DXFAttr(280, default=0),
        # Object schema number (currently set to 0)
        "value": DXFAttr(1, default=""),
    },
)
acdb_dict_var_group_codes = group_code_mapping(acdb_dict_var)


@factory.register_entity
class DictionaryVar(DXFObject):
    """
    DICTIONARYVAR objects are used by AutoCAD as a means to store named values
    in the database for setvar / getvar purposes without the need to add entries
    to the DXF HEADER section. System variables that are stored as
    DICTIONARYVAR objects are the following:

        - DEFAULTVIEWCATEGORY
        - DIMADEC
        - DIMASSOC
        - DIMDSEP
        - DRAWORDERCTL
        - FIELDEVAL
        - HALOGAP
        - HIDETEXT
        - INDEXCTL
        - INDEXCTL
        - INTERSECTIONCOLOR
        - INTERSECTIONDISPLAY
        - MSOLESCALE
        - OBSCOLOR
        - OBSLTYPE
        - OLEFRAME
        - PROJECTNAME
        - SORTENTS
        - UPDATETHUMBNAIL
        - XCLIPFRAME
        - XCLIPFRAME

    """

    DXFTYPE = "DICTIONARYVAR"
    DXFATTRIBS = DXFAttributes(base_class, acdb_dict_var)

    @property
    def value(self) -> str:
        """Get/set the value of the :class:`DictionaryVar` as string."""
        return self.dxf.get("value", "")

    @value.setter
    def value(self, data: str) -> None:
        self.dxf.set("value", str(data))

    def load_dxf_attribs(
        self, processor: Optional[SubclassProcessor] = None
    ) -> DXFNamespace:
        dxf = super().load_dxf_attribs(processor)
        if processor:
            processor.fast_load_dxfattribs(dxf, acdb_dict_var_group_codes, 1)
        return dxf

    def export_entity(self, tagwriter: AbstractTagWriter) -> None:
        """Export entity specific data as DXF tags."""
        super().export_entity(tagwriter)
        tagwriter.write_tag2(SUBCLASS_MARKER, acdb_dict_var.name)
        self.dxf.export_dxf_attribs(tagwriter, ["schema", "value"])
