# Purpose: Import data from another DXF document
# Copyright (c) 2013-2021, Manfred Moitzi
# License: MIT License
from typing import TYPE_CHECKING, Iterable, Set, cast, Union, List, Dict
import logging
from ezdxf.lldxf import const
from ezdxf.render.arrows import ARROWS
from ezdxf.entities import (
    DXFEntity,
    DXFGraphic,
    Hatch,
    Polyline,
    DimStyle,
    Dimension,
    Viewport,
    Linetype,
    Insert,
    MText,
)

if TYPE_CHECKING:
    from ezdxf.eztypes import (
        Drawing,
        BaseLayout,
        Layout,
        BlockLayout,
    )

logger = logging.getLogger("ezdxf")

IMPORT_TABLES = ["linetypes", "layers", "styles", "dimstyles"]
IMPORT_ENTITIES = {
    "LINE",
    "POINT",
    "CIRCLE",
    "ARC",
    "TEXT",
    "SOLID",
    "TRACE",
    "3DFACE",
    "SHAPE",
    "POLYLINE",
    "ATTRIB",
    "INSERT",
    "ELLIPSE",
    "MTEXT",
    "LWPOLYLINE",
    "SPLINE",
    "HATCH",
    "MESH",
    "XLINE",
    "RAY",
    "ATTDEF",
    "DIMENSION",
    "LEADER",  # dimension style override not supported!
    "VIEWPORT",
}


class Importer:
    """
    The :class:`Importer` class is central element for importing data from
    other DXF documents.

    Args:
        source: source :class:`~ezdxf.drawing.Drawing`
        target: target :class:`~ezdxf.drawing.Drawing`

    Attributes:
        source: source DXF document
        target: target DXF document
        used_layers: Set of used layer names as string, AutoCAD accepts layer
            names without a LAYER table entry.
        used_linetypes: Set of used linetype names as string, these linetypes
            require a TABLE entry or AutoCAD will crash.
        used_styles: Set of used text style names, these text styles require
            a TABLE entry or AutoCAD will crash.
        used_dimstyles: Set of used dimension style names, these dimension
            styles require a TABLE entry or AutoCAD will crash.

    """

    def __init__(self, source: "Drawing", target: "Drawing"):
        self.source: "Drawing" = source
        self.target: "Drawing" = target

        self.used_layers: Set[str] = set()
        self.used_linetypes: Set[str] = set()
        self.used_styles: Set[str] = set()
        self.used_shape_files: Set[str] = set()  # style entry without a name!
        self.used_dimstyles: Set[str] = set()
        self.used_arrows: Set[str] = set()
        self.handle_mapping: Dict[str, str] = dict()  # old_handle: new_handle

        # collects all imported INSERT entities, for later name resolving.
        self.imported_inserts: List[DXFEntity] = list()  # imported inserts

        # collects all imported block names and their assigned new name
        # imported_block[original_name] = new_name
        self.imported_blocks: Dict[str, str] = dict()
        self._default_plotstyle_handle = target.plotstyles["Normal"].dxf.handle
        self._default_material_handle = target.materials["Global"].dxf.handle

    def _add_used_resources(self, entity: "DXFEntity") -> None:
        """Register used resources."""
        self.used_layers.add(entity.get_dxf_attrib("layer", "0"))
        self.used_linetypes.add(entity.get_dxf_attrib("linetype", "BYLAYER"))
        if entity.is_supported_dxf_attrib("style"):
            self.used_styles.add(entity.get_dxf_attrib("style", "Standard"))
        if entity.is_supported_dxf_attrib("dimstyle"):
            self.used_dimstyles.add(
                entity.get_dxf_attrib("dimstyle", "Standard")
            )

    def _add_dimstyle_resources(self, dimstyle: "DimStyle") -> None:
        self.used_styles.add(dimstyle.get_dxf_attrib("dimtxsty", "Standard"))
        self.used_linetypes.add(dimstyle.get_dxf_attrib("dimltype", "BYLAYER"))
        self.used_linetypes.add(dimstyle.get_dxf_attrib("dimltex1", "BYLAYER"))
        self.used_linetypes.add(dimstyle.get_dxf_attrib("dimltex2", "BYLAYER"))
        self.used_arrows.add(dimstyle.get_dxf_attrib("dimblk", ""))
        self.used_arrows.add(dimstyle.get_dxf_attrib("dimblk1", ""))
        self.used_arrows.add(dimstyle.get_dxf_attrib("dimblk2", ""))
        self.used_arrows.add(dimstyle.get_dxf_attrib("dimldrblk", ""))

    def _add_linetype_resources(self, linetype: "Linetype") -> None:
        if linetype.pattern_tags.is_complex_type():
            style_handle = linetype.pattern_tags.get_style_handle()
            style = self.source.entitydb.get(style_handle)
            if style:
                if style.dxf.name == "":
                    # Shape file entries have no name!
                    self.used_shape_files.add(style.dxf.font)
                else:
                    self.used_styles.add(style.dxf.name)

    def import_tables(
        self, table_names: Union[str, Iterable[str]] = "*", replace=False
    ) -> None:
        """Import DXF tables from the source document into the target document.

        Args:
            table_names: iterable of tables names as strings, or a single table
                name as string or "*" for all supported tables
            replace: ``True`` to replace already existing table entries else
                ignore existing entries

        Raises:
            TypeError: unsupported table type

        """
        if isinstance(table_names, str):
            if table_names == "*":  # import all supported tables
                table_names = IMPORT_TABLES
            else:  # import one specific table
                table_names = (table_names,)
        for table_name in table_names:
            self.import_table(table_name, entries="*", replace=replace)

    def import_table(
        self, name: str, entries: Union[str, Iterable[str]] = "*", replace=False
    ) -> None:
        """
        Import specific table entries from the source document into the
        target document.

        Args:
            name: valid table names are "layers", "linetypes" and "styles"
            entries: Iterable of table names as strings, or a single table name
                or "*" for all table entries
            replace: ``True`` to replace the already existing table entry else
                ignore existing entries

        Raises:
            TypeError: unsupported table type

        """
        if name not in IMPORT_TABLES:
            raise TypeError(f'Table "{name}" import not supported.')
        source_table = getattr(self.source.tables, name)
        target_table = getattr(self.target.tables, name)

        if isinstance(entries, str):
            if entries == "*":  # import all table entries
                entries = (entry.dxf.name for entry in source_table)
            else:  # import just one table entry
                entries = (entries,)
        for entry_name in entries:
            try:
                table_entry = source_table.get(entry_name)
            except const.DXFTableEntryError:
                logger.warning(
                    f'Required table entry "{entry_name}" in table f{name} '
                    f"not found."
                )
                continue
            entry_name = table_entry.dxf.name
            if entry_name in target_table:
                if replace:
                    logger.debug(
                        f'Replacing already existing entry "{entry_name}" '
                        f"of {name} table."
                    )
                    target_table.remove(table_entry.dxf.name)
                else:
                    logger.debug(
                        f'Discarding already existing entry "{entry_name}" '
                        f"of {name} table."
                    )
                    continue

            if name == "layers":
                self.used_linetypes.add(
                    table_entry.get_dxf_attrib("linetype", "Continuous")
                )
            elif name == "dimstyles":
                self._add_dimstyle_resources(table_entry)
            elif name == "linetypes":
                self._add_linetype_resources(table_entry)

            # Duplicate table entry:
            new_table_entry = self._duplicate_table_entry(table_entry)
            target_table.add_entry(new_table_entry)

            # Register resource handles for mapping:
            self.handle_mapping[
                table_entry.dxf.handle
            ] = new_table_entry.dxf.handle

    def import_shape_files(self, fonts: Set[str]) -> None:
        """Import shape file table entries from the source document into the
        target document.
        Shape file entries are stored in the styles table but without a name.

        """
        for font in fonts:
            table_entry = self.source.styles.find_shx(font)
            # copy is not necessary, just create a new entry:
            new_table_entry = self.target.styles.get_shx(font)
            if table_entry:
                # Register resource handles for mapping:
                self.handle_mapping[
                    table_entry.dxf.handle
                ] = new_table_entry.dxf.handle
            else:
                logger.warning(f'Required shape file entry "{font}" not found.')

    def _set_table_entry_dxf_attribs(self, entity: "DXFEntity") -> None:
        entity.doc = self.target
        if entity.dxf.hasattr("plotstyle_handle"):
            entity.dxf.plotstyle_handle = self._default_plotstyle_handle
        if entity.dxf.hasattr("material_handle"):
            entity.dxf.material_handle = self._default_material_handle

    def _duplicate_table_entry(self, entry: "DXFEntity") -> "DXFEntity":
        # duplicate table entry
        new_entry = new_clean_entity(entry)
        self._set_table_entry_dxf_attribs(entry)

        # create a new handle and add entity to target entity database
        self.target.entitydb.add(new_entry)
        return new_entry

    def import_entity(
        self, entity: "DXFEntity", target_layout: "BaseLayout" = None
    ) -> None:
        """
        Imports a single DXF `entity` into `target_layout` or the modelspace
        of the target document, if `target_layout` is ``None``.

        Args:
            entity: DXF entity to import
            target_layout: any layout (modelspace, paperspace or block) from
                the target document

        Raises:
            DXFStructureError: `target_layout` is not a layout of target document

        """

        def set_dxf_attribs(e):
            e.doc = self.target
            # remove invalid resources
            e.dxf.discard("plotstyle_handle")
            e.dxf.discard("material_handle")
            e.dxf.discard("visualstyle_handle")

        if target_layout is None:
            target_layout = self.target.modelspace()
        elif target_layout.doc != self.target:
            raise const.DXFStructureError(
                "Target layout has to be a layout or block from the target "
                "document."
            )

        dxftype = entity.dxftype()
        if dxftype not in IMPORT_ENTITIES:
            logger.debug(f"Import of {str(entity)} not supported")
            return
        self._add_used_resources(entity)

        try:
            new_entity = cast("DXFGraphic", new_clean_entity(entity))
        except const.DXFTypeError:
            logger.debug(f"Copying for DXF type {dxftype} not supported.")
            return

        set_dxf_attribs(new_entity)
        self.target.entitydb.add(new_entity)
        target_layout.add_entity(new_entity)

        try:  # additional processing
            getattr(self, "_import_" + dxftype.lower())(new_entity)
        except AttributeError:
            pass

    def _import_insert(self, insert: "Insert"):
        self.imported_inserts.append(insert)
        # remove all possible source document dependencies from sub entities
        for attrib in insert.attribs:
            remove_dependencies(attrib)

    def _import_polyline(self, polyline: "Polyline"):
        # remove all possible source document dependencies from sub entities
        for vertex in polyline.vertices:
            remove_dependencies(vertex)

    def _import_hatch(self, hatch: "Hatch"):
        hatch.dxf.discard("associative")

    def _import_viewport(self, viewport: "Viewport"):
        viewport.dxf.discard("sun_handle")
        viewport.dxf.discard("clipping_boundary_handle")
        viewport.dxf.discard("ucs_handle")
        viewport.dxf.discard("ucs_base_handle")
        viewport.dxf.discard("background_handle")
        viewport.dxf.discard("shade_plot_handle")
        viewport.dxf.discard("visual_style_handle")
        viewport.dxf.discard("ref_vp_object_1")
        viewport.dxf.discard("ref_vp_object_2")
        viewport.dxf.discard("ref_vp_object_3")
        viewport.dxf.discard("ref_vp_object_4")

    def _import_dimension(self, dimension: "Dimension"):
        if dimension.virtual_block_content:
            for entity in dimension.virtual_block_content:
                if isinstance(entity, Insert):  # import arrow blocks
                    self.import_block(entity.dxf.name, rename=False)
                self._add_used_resources(entity)
        else:
            logger.error(
                "The required geometry block for DIMENSION is not defined."
            )

    def import_entities(
        self,
        entities: Iterable["DXFEntity"],
        target_layout: "BaseLayout" = None,
    ) -> None:
        """Import all `entities` into `target_layout` or the modelspace of the
        target document, if `target_layout` is ``None``.

        Args:
            entities: Iterable of DXF entities
            target_layout: any layout (modelspace, paperspace or block) from
                the target document

        Raises:
            DXFStructureError: `target_layout` is not a layout of target document

        """
        for entity in entities:
            self.import_entity(entity, target_layout)

    def import_modelspace(self, target_layout: "BaseLayout" = None) -> None:
        """Import all entities from source modelspace into `target_layout` or
        the modelspace of the target document, if `target_layout` is ``None``.

        Args:
            target_layout: any layout (modelspace, paperspace or block) from
                the target document

        Raises:
            DXFStructureError: `target_layout` is not a layout of target document

        """
        self.import_entities(
            self.source.modelspace(), target_layout=target_layout
        )

    def recreate_source_layout(self, name: str) -> "Layout":
        """Recreate source paperspace layout `name` in the target document.
        The layout will be renamed if `name` already exist in the target
        document. Returns target modelspace for layout name "Model".

        Args:
            name: layout name as string

        Raises:
            KeyError: if source layout `name` not exist

        """

        def get_target_name():
            tname = name
            base_name = name
            count = 1
            while tname in self.target.layouts:
                tname = base_name + str(count)
                count += 1

            return tname

        def clear(dxfattribs: dict) -> dict:
            def discard(name: str):
                try:
                    del dxfattribs[name]
                except KeyError:
                    pass

            discard("handle")
            discard("owner")
            discard("taborder")
            discard("shade_plot_handle")
            discard("block_record_handle")
            discard("viewport_handle")
            discard("ucs_handle")
            discard("base_ucs_handle")
            return dxfattribs

        if name.lower() == "model":
            return self.target.modelspace()

        source_layout = self.source.layouts.get(name)  # raises KeyError
        target_name = get_target_name()
        dxfattribs = clear(source_layout.dxf_layout.dxfattribs())
        target_layout = self.target.layouts.new(
            target_name, dxfattribs=dxfattribs
        )
        return target_layout

    def import_paperspace_layout(self, name: str) -> "Layout":
        """Import paperspace layout `name` into the target document.

        Recreates the source paperspace layout in the target document, renames
        the target paperspace if a paperspace with same `name` already exist
        and imports all entities from the source paperspace into the target
        paperspace.

        Args:
            name: source paper space name as string

        Returns: new created target paperspace :class:`Layout`

        Raises:
            KeyError: source paperspace does not exist
            DXFTypeError: invalid modelspace import

        """
        if name.lower() == "model":
            raise const.DXFTypeError(
                "Can not import modelspace, use method import_modelspace()."
            )
        source_layout = self.source.layouts.get(name)
        target_layout = self.recreate_source_layout(name)
        self.import_entities(source_layout, target_layout)
        return target_layout

    def import_paperspace_layouts(self) -> None:
        """Import all paperspace layouts and their content into the target
        document.
        Target layouts will be renamed if a layout with the same name already
        exist. Layouts will be imported in original tab order.

        """
        for name in self.source.layouts.names_in_taborder():
            if name.lower() != "model":  # do not import modelspace
                self.import_paperspace_layout(name)

    def import_blocks(self, block_names: Iterable[str], rename=False) -> None:
        """Import all BLOCK definitions from source document.

        If a BLOCK already exist the BLOCK will be renamed if argument
        `rename` is ``True``, otherwise the existing BLOCK in the target
        document will be used instead of the BLOCK from the source document.
        Required name resolving for imported BLOCK references (INSERT), will be
        done in the :meth:`Importer.finalize` method.

        Args:
            block_names: names of BLOCK definitions to import
            rename: rename BLOCK if a BLOCK with the same name already exist in
                target document

        Raises:
            ValueError: BLOCK in source document not found (defined)

        """
        for block_name in block_names:
            self.import_block(block_name, rename=rename)

    def import_block(self, block_name: str, rename=True) -> str:
        """Import one BLOCK definition from source document.

        If the BLOCK already exist the BLOCK will be renamed if argument
        `rename` is ``True``, otherwise the existing BLOCK in the target
        document will be used instead of the BLOCK in the source document.
        Required name resolving for imported block references (INSERT), will be
        done in the :meth:`Importer.finalize` method.

        To replace an existing BLOCK in the target document, just delete it
        before importing data:
        :code:`target.blocks.delete_block(block_name, safe=False)`

        Args:
            block_name: name of BLOCK to import
            rename: rename BLOCK if a BLOCK with the same name already exist in
                target document

        Returns: (renamed) BLOCK name

        Raises:
            ValueError: BLOCK in source document not found (defined)

        """

        def get_new_block_name() -> str:
            num = 0
            name = block_name
            while name in target_blocks:
                name = block_name + str(num)
                num += 1
            return name

        try:  # already imported block?
            return self.imported_blocks[block_name]
        except KeyError:
            pass

        try:
            source_block = self.source.blocks[block_name]
        except const.DXFKeyError:
            raise ValueError(f'Source block "{block_name}" not found.')

        target_blocks = self.target.blocks
        if (block_name in target_blocks) and (rename is False):
            self.imported_blocks[block_name] = block_name
            return block_name

        new_block_name = get_new_block_name()
        block = source_block.block
        assert block is not None
        target_block = target_blocks.new(
            new_block_name,
            base_point=block.dxf.base_point,
            dxfattribs={
                "description": block.dxf.description,
                "flags": block.dxf.flags,
                "xref_path": block.dxf.xref_path,
            },
        )
        self.import_entities(source_block, target_layout=target_block)
        self.imported_blocks[block_name] = new_block_name
        return new_block_name

    def _create_missing_arrows(self):
        """Create or import required arrow blocks, used by the LEADER or the
        DIMSTYLE entity, which are not imported automatically because they are
        not used in an anonymous DIMENSION geometry BLOCK.

        """
        self.used_arrows.discard(
            ""
        )  # standard default arrow '' needs no block definition
        for arrow_name in self.used_arrows:
            if ARROWS.is_acad_arrow(arrow_name):
                self.target.acquire_arrow(arrow_name)
            else:
                self.import_block(arrow_name, rename=False)

    def _resolve_inserts(self) -> None:
        """Resolve BLOCK names of imported BLOCK reference entities (INSERT).

        This is required for the case the name of the imported BLOCK collides
        with an already existing BLOCK in the target document and the conflict
        resolving method is "rename".

        """
        while len(self.imported_inserts):
            inserts = list(self.imported_inserts)
            # clear imported inserts, block import may append additional inserts
            self.imported_inserts = []
            for insert in inserts:
                block_name = self.import_block(insert.dxf.name)
                insert.dxf.name = block_name

    def _import_required_table_entries(self) -> None:
        """Import required table entries collected while importing entities
        into the target document.

        """
        # 1. dimstyles import adds additional required linetype and style
        # resources and required arrows
        if len(self.used_dimstyles):
            self.import_table("dimstyles", self.used_dimstyles)

        # 2. layers import adds additional required linetype resources
        if len(self.used_layers):
            self.import_table("layers", self.used_layers)

        # 3. complex linetypes adds additional required style resources
        if len(self.used_linetypes):
            self.import_table("linetypes", self.used_linetypes)

        # 4. Text styles do not add additional required resources
        if len(self.used_styles):
            self.import_table("styles", self.used_styles)

        # 5. Shape files are text style entries without a name
        if len(self.used_shape_files):
            self.import_shape_files(self.used_shape_files)

        # 6. Update text style handles of imported complex linetypes:
        self.update_complex_linetypes()

    def update_complex_linetypes(self):
        for linetype in self.target.linetypes:  # type: Linetype
            if linetype.pattern_tags.is_complex_type():
                old_handle = linetype.pattern_tags.get_style_handle()
                new_handle = self.handle_mapping.get(old_handle)
                if new_handle:
                    linetype.pattern_tags.set_style_handle(new_handle)

    def finalize(self) -> None:
        """Finalize the import by importing required table entries and BLOCK
        definitions, without finalization the target document is maybe invalid
        for AutoCAD. Call the :meth:`~Importer.finalize()` method as last step
        of the import process.

        """
        self._resolve_inserts()
        self._import_required_table_entries()
        self._create_missing_arrows()


def new_clean_entity(
    entity: "DXFEntity", keep_xdata: bool = False
) -> "DXFEntity":
    """Copy entity and remove all external dependencies.

    Args:
        entity: DXF entity
        keep_xdata: keep xdata flag

    """
    new_entity = entity.copy()
    new_entity.doc = None
    return remove_dependencies(new_entity, keep_xdata=keep_xdata)


def remove_dependencies(
    entity: "DXFEntity", keep_xdata: bool = False
) -> "DXFEntity":
    """Remove all external dependencies.

    Args:
        entity: DXF entity
        keep_xdata: keep xdata flag

    """
    entity.appdata = None
    entity.reactors = None
    entity.extension_dict = None
    if not keep_xdata:
        entity.xdata = None
    return entity
