# Copyright (c) 2020-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
    Iterable,
    Iterator,
    cast,
    BinaryIO,
    Optional,
    Union,
    Any,
)
from io import StringIO
from pathlib import Path
from ezdxf.lldxf.const import DXFStructureError
from ezdxf.lldxf.extendedtags import ExtendedTags, DXFTag
from ezdxf.lldxf.tagwriter import TagWriter
from ezdxf.lldxf.tagger import tag_compiler, ascii_tags_loader
from ezdxf.filemanagement import dxf_file_info
from ezdxf.lldxf import fileindex

from ezdxf.entities import DXFGraphic, DXFEntity, Polyline, Insert
from ezdxf.entities import factory
from ezdxf.entities.subentity import entity_linker
from ezdxf.tools.codepage import toencoding

__all__ = ["opendxf", "single_pass_modelspace", "modelspace"]

SUPPORTED_TYPES = {
    "ARC",
    "LINE",
    "CIRCLE",
    "ELLIPSE",
    "POINT",
    "LWPOLYLINE",
    "SPLINE",
    "3DFACE",
    "SOLID",
    "TRACE",
    "SHAPE",
    "POLYLINE",
    "VERTEX",
    "SEQEND",
    "MESH",
    "TEXT",
    "MTEXT",
    "HATCH",
    "INSERT",
    "ATTRIB",
    "ATTDEF",
    "RAY",
    "XLINE",
    "DIMENSION",
    "LEADER",
    "IMAGE",
    "WIPEOUT",
    "HELIX",
    "MLINE",
    "MLEADER",
}

Filename = Union[Path, str]


class IterDXF:
    """Iterator for DXF entities stored in the modelspace.

    Args:
        name: filename, has to be a seekable file.
        errors: specify decoding error handler

            - "surrogateescape" to preserve possible binary data (default)
            - "ignore" to use the replacement char U+FFFD "\ufffd" for invalid data
            - "strict" to raise an :class:`UnicodeDecodeError`exception  for invalid data

    Raises:
        DXFStructureError: invalid or incomplete DXF file
        UnicodeDecodeError: if `errors` is "strict" and a decoding error occurs

    """

    def __init__(self, name: Filename, errors: str = "surrogateescape"):
        self.structure, self.sections = self._load_index(str(name))
        self.errors = errors
        self.file: BinaryIO = open(name, mode="rb")
        if "ENTITIES" not in self.sections:
            raise DXFStructureError("ENTITIES section not found.")
        if self.structure.version > "AC1009" and "OBJECTS" not in self.sections:
            raise DXFStructureError("OBJECTS section not found.")

    def _load_index(
        self, name: str
    ) -> tuple[fileindex.FileStructure, dict[str, int]]:
        structure = fileindex.load(name)
        sections: dict[str, int] = dict()
        new_index = []
        for e in structure.index:
            if e.code == 0:
                new_index.append(e)
            elif e.code == 2:
                sections[e.value] = len(new_index) - 1
            # remove all other tags like handles (code == 5)
        structure.index = new_index
        return structure, sections

    @property
    def encoding(self):
        return self.structure.encoding

    @property
    def dxfversion(self):
        return self.structure.version

    def export(self, name: Filename) -> IterDXFWriter:
        """Returns a companion object to export parts from the source DXF file
        into another DXF file, the new file will have the same HEADER, CLASSES,
        TABLES, BLOCKS and OBJECTS sections, which guarantees all necessary
        dependencies are present in the new file.

        Args:
            name: filename, no special requirements

        """
        doc = IterDXFWriter(name, self)
        # Copy everything from start of source DXF until the first entity
        # of the ENTITIES section to the new DXF.
        location = self.structure.index[self.sections["ENTITIES"] + 1].location
        self.file.seek(0)
        data = self.file.read(location)
        doc.write_data(data)
        return doc

    def copy_objects_section(self, f: BinaryIO) -> None:
        start_index = self.sections["OBJECTS"]
        try:
            end_index = self.structure.get(0, "ENDSEC", start_index)
        except ValueError:
            raise DXFStructureError(f"ENDSEC of OBJECTS section not found.")

        start_location = self.structure.index[start_index].location
        end_location = self.structure.index[end_index + 1].location
        count = end_location - start_location
        self.file.seek(start_location)
        data = self.file.read(count)
        f.write(data)

    def modelspace(
        self, types: Optional[Iterable[str]] = None
    ) -> Iterable[DXFGraphic]:
        """Returns an iterator for all supported DXF entities in the
        modelspace. These entities are regular :class:`~ezdxf.entities.DXFGraphic`
        objects but without a valid document assigned. It is **not**
        possible to add these entities to other `ezdxf` documents.

        It is only possible to recreate the objects by factory functions base
        on attributes of the source entity.
        For MESH, POLYMESH and POLYFACE it is possible to use the
        :class:`~ezdxf.render.MeshTransformer` class to render (recreate) this
        objects as new entities in another document.

        Args:
            types: DXF types like ``['LINE', '3DFACE']`` which should be
                returned, ``None`` returns all supported types.

        """
        linked_entity = entity_linker()
        queued = None
        requested_types = _requested_types(types)
        for entity in self.load_entities(
            self.sections["ENTITIES"] + 1, requested_types
        ):
            if not linked_entity(entity) and entity.dxf.paperspace == 0:
                # queue one entity for collecting linked entities:
                # VERTEX, ATTRIB
                if queued:
                    yield queued
                queued = entity
        if queued:
            yield queued

    def load_entities(
        self, start: int, requested_types: set[str]
    ) -> Iterable[DXFGraphic]:
        def to_str(data: bytes) -> str:
            return data.decode(self.encoding, errors=self.errors).replace(
                "\r\n", "\n"
            )

        index = start
        entry = self.structure.index[index]
        self.file.seek(entry.location)
        while entry.value != "ENDSEC":
            index += 1
            next_entry = self.structure.index[index]
            size = next_entry.location - entry.location
            data = self.file.read(size)
            if entry.value in requested_types:
                xtags = ExtendedTags.from_text(to_str(data))
                yield factory.load(xtags)  # type: ignore
            entry = next_entry

    def close(self):
        """Safe closing source DXF file."""
        self.file.close()


class IterDXFWriter:
    def __init__(self, name: Filename, loader: IterDXF):
        self.name = str(name)
        self.file: BinaryIO = open(name, mode="wb")
        self.text = StringIO()
        self.entity_writer = TagWriter(self.text, loader.dxfversion)
        self.loader = loader

    def write_data(self, data: bytes):
        self.file.write(data)

    def write(self, entity: DXFGraphic):
        """Write a DXF entity from the source DXF file to the export file.

        Don't write entities from different documents than the source DXF file,
        dependencies and resources will not match, maybe it will work once, but
        not in a reliable way for different DXF documents.

        """
        # Not necessary to remove this dependencies by copying
        # them into the same document frame
        # ---------------------------------
        # remove all possible dependencies
        # entity.xdata = None
        # entity.appdata = None
        # entity.extension_dict = None
        # entity.reactors = None
        # reset text stream
        self.text.seek(0)
        self.text.truncate()

        if entity.dxf.handle is None:  # DXF R12 without handles
            self.entity_writer.write_handles = False

        entity.export_dxf(self.entity_writer)
        if entity.dxftype() == "POLYLINE":
            polyline = cast(Polyline, entity)
            for vertex in polyline.vertices:
                vertex.export_dxf(self.entity_writer)
            polyline.seqend.export_dxf(self.entity_writer)  # type: ignore
        elif entity.dxftype() == "INSERT":
            insert = cast(Insert, entity)
            if insert.attribs_follow:
                for attrib in insert.attribs:
                    attrib.export_dxf(self.entity_writer)
                insert.seqend.export_dxf(self.entity_writer)  # type: ignore
        data = self.text.getvalue().encode(self.loader.encoding)
        self.file.write(data)

    def close(self):
        """Safe closing of exported DXF file. Copying of OBJECTS section
        happens only at closing the file, without closing the new DXF file is
        invalid.
        """
        self.file.write(b"  0\r\nENDSEC\r\n")  # for ENTITIES section
        if self.loader.dxfversion > "AC1009":
            self.loader.copy_objects_section(self.file)
        self.file.write(b"  0\r\nEOF\r\n")
        self.file.close()


def opendxf(filename: Filename, errors: str = "surrogateescape") -> IterDXF:
    """Open DXF file for iterating, be sure to open valid DXF files, no DXF
    structure checks will be applied.

    Use this function to split up big DXF files as shown in the example above.

    Args:
        filename: DXF filename of a seekable DXF file.
        errors: specify decoding error handler

            - "surrogateescape" to preserve possible binary data (default)
            - "ignore" to use the replacement char U+FFFD "\ufffd" for invalid data
            - "strict" to raise an :class:`UnicodeDecodeError` exception for invalid data

    Raises:
        DXFStructureError: invalid or incomplete DXF file
        UnicodeDecodeError: if `errors` is "strict" and a decoding error occurs

    """
    return IterDXF(filename, errors=errors)


def modelspace(
    filename: Filename,
    types: Optional[Iterable[str]] = None,
    errors: str = "surrogateescape",
) -> Iterable[DXFGraphic]:
    """Iterate over all modelspace entities as :class:`DXFGraphic` objects of
    a seekable file.

    Use this function to iterate "quick" over modelspace entities of a DXF file,
    filtering DXF types may speed up things if many entity types will be skipped.

    Args:
        filename: filename of a seekable DXF file
        types: DXF types like ``['LINE', '3DFACE']`` which should be returned,
            ``None`` returns all supported types.
        errors: specify decoding error handler

            - "surrogateescape" to preserve possible binary data (default)
            - "ignore" to use the replacement char U+FFFD "\ufffd" for invalid data
            - "strict" to raise an :class:`UnicodeDecodeError` exception for invalid data

    Raises:
        DXFStructureError: invalid or incomplete DXF file
        UnicodeDecodeError: if `errors` is "strict" and a decoding error occurs

    """
    info = dxf_file_info(str(filename))
    prev_code: int = -1
    prev_value: Any = ""
    entities = False
    requested_types = _requested_types(types)

    with open(filename, mode="rt", encoding=info.encoding, errors=errors) as fp:
        tagger = ascii_tags_loader(fp)
        queued: Optional[DXFEntity] = None
        tags: list[DXFTag] = []
        linked_entity = entity_linker()

        for tag in tag_compiler(tagger):
            code = tag.code
            value = tag.value
            if entities:
                if code == 0:
                    if len(tags) and tags[0].value in requested_types:
                        entity = factory.load(ExtendedTags(tags))
                        if (
                            not linked_entity(entity)
                            and entity.dxf.paperspace == 0
                        ):
                            # queue one entity for collecting linked entities:
                            # VERTEX, ATTRIB
                            if queued:
                                yield queued  # type: ignore
                            queued = entity
                    tags = [tag]
                else:
                    tags.append(tag)
                if code == 0 and value == "ENDSEC":
                    if queued:
                        yield queued  # type: ignore
                    return
                continue  # if entities - nothing else matters
            elif code == 2 and prev_code == 0 and prev_value == "SECTION":
                entities = value == "ENTITIES"

            prev_code = code
            prev_value = value


def single_pass_modelspace(
    stream: BinaryIO,
    types: Optional[Iterable[str]] = None,
    errors: str = "surrogateescape",
) -> Iterable[DXFGraphic]:
    """Iterate over all modelspace entities as :class:`DXFGraphic` objects in
    one single pass.

    Use this function to 'quick' iterate over modelspace entities of a **not**
    seekable binary DXF stream, filtering DXF types may speed up things if many
    entity types will be skipped.

    Args:
        stream: (not seekable) binary DXF stream
        types: DXF types like ``['LINE', '3DFACE']`` which should be returned,
            ``None`` returns all supported types.
        errors: specify decoding error handler

            - "surrogateescape" to preserve possible binary data (default)
            - "ignore" to use the replacement char U+FFFD "\ufffd" for invalid data
            - "strict" to raise an :class:`UnicodeDecodeError` exception for invalid data

    Raises:
        DXFStructureError: Invalid or incomplete DXF file
        UnicodeDecodeError: if `errors` is "strict" and a decoding error occurs

    """
    fetch_header_var: Optional[str] = None
    encoding = "cp1252"
    version = "AC1009"
    prev_code: int = -1
    prev_value: str = ""
    entities = False
    requested_types = _requested_types(types)

    for code, value in binary_tagger(stream):
        if code == 0 and value == b"ENDSEC":
            break
        elif code == 2 and prev_code == 0 and value != b"HEADER":
            # (0, SECTION), (2, name)
            # First section is not the HEADER section
            entities = value == b"ENTITIES"
            break
        elif code == 9 and value == b"$DWGCODEPAGE":
            fetch_header_var = "ENCODING"
        elif code == 9 and value == b"$ACADVER":
            fetch_header_var = "VERSION"
        elif fetch_header_var == "ENCODING":
            encoding = toencoding(value.decode())
            fetch_header_var = None
        elif fetch_header_var == "VERSION":
            version = value.decode()
            fetch_header_var = None
        prev_code = code

    if version >= "AC1021":
        encoding = "utf-8"

    queued: Optional[DXFGraphic] = None
    tags: list[DXFTag] = []
    linked_entity = entity_linker()

    for tag in tag_compiler(binary_tagger(stream, encoding, errors)):
        code = tag.code
        value = tag.value
        if entities:
            if code == 0 and value == "ENDSEC":
                if queued:
                    yield queued
                return
            if code == 0:
                if len(tags) and tags[0].value in requested_types:
                    entity = cast(DXFGraphic, factory.load(ExtendedTags(tags)))
                    if not linked_entity(entity) and entity.dxf.paperspace == 0:
                        # queue one entity for collecting linked entities:
                        # VERTEX, ATTRIB
                        if queued:
                            yield queued
                        queued = entity
                tags = [tag]
            else:
                tags.append(tag)
            continue  # if entities - nothing else matters
        elif code == 2 and prev_code == 0 and prev_value == "SECTION":
            entities = value == "ENTITIES"

        prev_code = code
        prev_value = value


def binary_tagger(
    file: BinaryIO,
    encoding: Optional[str] = None,
    errors: str = "surrogateescape",
) -> Iterator[DXFTag]:
    while True:
        try:
            try:
                code = int(file.readline())
            except ValueError:
                raise DXFStructureError(f"Invalid group code")
            value = file.readline().rstrip(b"\r\n")
            yield DXFTag(
                code,
                value.decode(encoding, errors=errors) if encoding else value,
            )
        except IOError:
            return


def _requested_types(types: Optional[Iterable[str]]) -> set[str]:
    if types:
        requested = SUPPORTED_TYPES.intersection(set(types))
        if "POLYLINE" in requested:
            requested.add("SEQEND")
            requested.add("VERTEX")
        if "INSERT" in requested:
            requested.add("SEQEND")
            requested.add("ATTRIB")
    else:
        requested = SUPPORTED_TYPES
    return requested
