# Created: 11.03.2011
# Copyright (c) 2011-2018, Manfred Moitzi
# License: MIT License
from typing import TYPE_CHECKING, TextIO, Iterable
from datetime import datetime
import io
import logging
from itertools import chain
from ezdxf.database import EntityDB
from ezdxf.lldxf.const import DXFVersionError, acad_release, BLK_XREF, BLK_EXTERNAL, DXFValueError
from ezdxf.lldxf.loader import load_dxf_structure, fill_database
from ezdxf.dxffactory import dxffactory
from ezdxf.templates import TemplateLoader
from ezdxf.options import options
from ezdxf.tools.codepage import tocodepage, toencoding
from ezdxf.sections.sections import Sections
from ezdxf.tools.juliandate import juliandate
from ezdxf.lldxf import repair
from ezdxf.tools import guid
from ezdxf.tracker import Tracker
from ezdxf.query import EntityQuery
from ezdxf.groupby import groupby
from ezdxf.render.dimension import DimensionRenderer

logger = logging.getLogger('ezdxf')

if TYPE_CHECKING:
    from .eztypes import HandleGenerator, DXFTag, LayoutType, SectionDict
    from .eztypes import GroupManager, MaterialManager, MLeaderStyleManager, MLineStyleManager
    from .eztypes import SectionType, HeaderSection, BlocksSection, Table, ViewportTable


class Drawing:
    """
    The Central Data Object
    """

    def __init__(self, tagger: Iterable['DXFTag']):
        """
        Build a new DXF drawing from a steam of DXF tags.

        Args:
             tagger: generator or list of DXF tags as DXFTag() objects
        """

        def get_header(sections: 'SectionDict') -> 'SectionType':
            from .sections.header import HeaderSection
            header_entities = sections.get('HEADER', [None])[0]  # all tags in the first DXF structure entity
            return HeaderSection(header_entities)

        self.tracker = Tracker()
        self._dimension_renderer = DimensionRenderer()  # set DIMENSION rendering engine
        self._groups = None  # type: GroupManager  # read only
        self._materials = None  # type: MaterialManager # read only
        self._mleader_styles = None  # type: MLeaderStyleManager # read only
        self._mline_styles = None  # type: MLineStyleManager # read only
        self._acad_compatible = True  # will generated DXF file compatible with AutoCAD
        self._acad_incompatibility_reason = set()  # avoid multiple warnings for same reason
        self.filename = None  # type: str # read/write
        self.entitydb = EntityDB()  # read only
        sections = load_dxf_structure(tagger)  # load complete DXF entity structure
        # create section HEADER
        header = get_header(sections)
        self.dxfversion = header.get('$ACADVER', 'AC1009')  # type: str # read only
        self.dxffactory = dxffactory(self)  # read only, requires self.dxfversion
        self.encoding = toencoding(header.get('$DWGCODEPAGE', 'ANSI_1252'))  # type: str # read/write
        # get handle seed
        seed = header.get('$HANDSEED', str(self.entitydb.handles))  # type: str
        # setup handles
        self.entitydb.handles.reset(seed)
        # store all necessary DXF entities in the drawing database
        fill_database(self.entitydb, sections, dxfversion=self.dxfversion)
        # create sections: TABLES, BLOCKS, ENTITIES, CLASSES, OBJECTS
        self.sections = Sections(sections, drawing=self, header=header)

        if self.dxfversion > 'AC1009':
            self.rootdict = self.objects.rootdict
            self.objects.setup_objects_management_tables(self.rootdict)  # create missing tables
            if self.dxfversion in ('AC1012', 'AC1014'):  # releases R13 and R14
                repair.upgrade_to_ac1015(self)
            # some applications don't setup properly the model and paper space layouts
            repair.setup_layouts(self)
            self._groups = self.objects.groups()
            self._materials = self.objects.materials()
            self._mleader_styles = self.objects.mleader_styles()
            self._mline_styles = self.objects.mline_styles()
        else:  # dxfversion <= 'AC1009' do cleanup work, before building layouts
            if self.dxfversion < 'AC1009':  # legacy DXF version
                repair.upgrade_to_ac1009(self)  # upgrade to DXF format AC1009 (DXF R12)
            repair.cleanup_r12(self)
            # ezdxf puts automatically handles into all entities added to the entities database
            # write R12 without handles, by setting $HANDLING = 0
            self.header['$HANDLING'] = 1  # write handles by default

        self.layouts = self.dxffactory.get_layouts()

    @property
    def acad_release(self) -> str:
        return acad_release.get(self.dxfversion, "unknown")

    @property
    def acad_compatible(self) -> bool:
        return self._acad_compatible

    def add_acad_incompatibility_message(self, msg: str):
        self._acad_compatible = False
        if msg not in self._acad_incompatibility_reason:
            self._acad_incompatibility_reason.add(msg)
            logger.warning('Drawing is incompatible to AutoCAD, because {}.'.format(msg))

    @property
    def _handles(self) -> 'HandleGenerator':
        return self.entitydb.handles

    @property
    def header(self) -> 'HeaderSection':
        return self.sections.header

    @property
    def layers(self) -> 'Table':
        return self.sections.tables.layers

    @property
    def linetypes(self) -> 'Table':
        return self.sections.tables.linetypes

    @property
    def styles(self) -> 'Table':
        return self.sections.tables.styles

    @property
    def dimstyles(self) -> 'Table':
        return self.sections.tables.dimstyles

    @property
    def ucs(self) -> 'Table':
        return self.sections.tables.ucs

    @property
    def appids(self) -> 'Table':
        return self.sections.tables.appids

    @property
    def views(self) -> 'Table':
        return self.sections.tables.views

    @property
    def block_records(self) -> 'Table':
        return self.sections.tables.block_records

    @property
    def viewports(self) -> 'ViewportTable':
        return self.sections.tables.viewports

    @property
    def blocks(self) -> 'BlocksSection':
        return self.sections.blocks

    @property
    def groups(self) -> 'GroupManager':
        if self.dxfversion <= 'AC1009':
            raise DXFVersionError('Groups not supported in DXF version R12.')
        return self._groups

    @property
    def materials(self) -> 'MaterialManager':
        if self.dxfversion <= 'AC1009':
            raise DXFVersionError('Materials not supported in DXF version R12.')
        return self._materials

    @property
    def mleader_styles(self) -> 'MLeaderStyleManager':
        if self.dxfversion <= 'AC1009':
            raise DXFVersionError('MLeaderStyles not supported in DXF version R12.')
        return self._mleader_styles

    @property
    def mline_styles(self) -> 'MLineStyleManager':
        if self.dxfversion <= 'AC1009':
            raise DXFVersionError('MLineStyles not supported in DXF version R12.')
        return self._mline_styles

    @property
    def dimension_renderer(self) -> DimensionRenderer:
        return self._dimension_renderer

    @dimension_renderer.setter
    def dimension_renderer(self, renderer: DimensionRenderer) -> None:
        """
        Set your own dimension line renderer if needed.

        see also: ezdxf.render.dimension

        """
        self._dimension_renderer = renderer

    def modelspace(self) -> 'LayoutType':
        return self.layouts.modelspace()

    def layout(self, name: str = None) -> 'LayoutType':
        return self.layouts.get(name)

    def layout_names(self) -> Iterable[str]:
        return list(self.layouts.names())

    def delete_layout(self, name):
        if self.dxfversion > 'AC1009':
            if name not in self.layouts:
                raise DXFValueError("Layout '{}' does not exist.".format(name))
            else:
                self.layouts.delete(name)
        else:
            raise DXFVersionError('delete_layout() not supported for DXF version R12.')

    def new_layout(self, name, dxfattribs=None):
        if self.dxfversion > 'AC1009':
            if name in self.layouts:
                raise DXFValueError("Layout '{}' already exists.".format(name))
            else:
                return self.layouts.new(name, dxfattribs)
        else:
            raise DXFVersionError('new_layout() not supported for DXF version R12.')

    def layouts_and_blocks(self):
        """
        Iterate over all layouts (mode space and paper space) and all block definitions.

        Returns: yields Layout() objects

        """
        # DXF R12: model space and paper space layouts not linked into the associated BLOCK entity
        if self.dxfversion <= 'AC1009':
            return chain(self.layouts, self.blocks)
        # DXF R2000+: all layout spaces linked into their associated BLOCK entity
        else:
            return iter(self.blocks)

    def chain_layouts_and_blocks(self):
        """
        Chain entity spaces of all layouts and blocks. Yields an iterator for all entities in all layouts and blocks.

        Returns: yields all entities as DXFEntity() objects

        """
        layouts = list(self.layouts_and_blocks())
        return chain.from_iterable(layouts)

    def get_active_layout_key(self):
        if self.dxfversion > 'AC1009':
            try:
                active_layout_block_record = self.block_records.get('*Paper_Space')  # block names are case insensitive
                return active_layout_block_record.dxf.handle
            except DXFValueError:
                return None
        else:
            return self.layout().layout_key  # AC1009 supports just one layout and this is the active one

    def get_active_entity_space_layout_keys(self):
        layout_keys = [self.modelspace().layout_key]
        active_layout_key = self.get_active_layout_key()
        if active_layout_key is not None:
            layout_keys.append(active_layout_key)
        return layout_keys

    @property
    def entities(self):
        return self.sections.entities

    @property
    def objects(self):
        return self.sections.objects

    def get_dxf_entity(self, handle):
        """
        Get entity by *handle* from entity database.

        Low level access to DXF entities database. Raises *KeyError* if handle don't exists.
        Returns DXFEntity() or inherited.

        If you just need the raw DXF tags use::

            tags = Drawing.entitydb[handle]  # raises KeyError, if handle don't exist
            tags = Drawing.entitydb.get(handle)  # returns a default value, if handle don't exist (None by default)

        type of tags: ExtendedTags()
        """
        return self.dxffactory.wrap_handle(handle)

    def add_image_def(self, filename, size_in_pixel, name=None):
        """
        Add an image definition to the objects section.

        For AutoCAD works best with absolute image paths but not good, you have to update external references manually
        in AutoCAD, which is not possible in TrueView. If you drawing units differ from 1 meter, you also have to use:
        Drawing.set_raster_variables().

        Args:
            filename: image file name (absolute path works best for AutoCAD)
            size_in_pixel: image size in pixel as (x, y) tuple
            name: image name for internal use, None for using filename as name (best for AutoCAD)

        """
        if self.dxfversion < 'AC1015':
            raise DXFVersionError('The IMAGE entity needs at least DXF version R2000 or later.')

        if 'ACAD_IMAGE_VARS' not in self.rootdict:
            self.objects.set_raster_variables(frame=0, quality=1, units=3)
        if name is None:
            name = filename
        return self.objects.add_image_def(filename, size_in_pixel, name)

    def set_raster_variables(self, frame=0, quality=1, units='m'):
        """
        Set raster variables.

        Args:
            frame: 0 = do not show image frame; 1 = show image frame
            quality: 0 = draft; 1 = high
            units: units for inserting images. This is what one drawing unit is equal to for the purpose of inserting
                   and scaling images with an associated resolution

                   'mm' = Millimeter
                   'cm' = Centimeter
                   'm' = Meter (ezdxf default)
                   'km' = Kilometer
                   'in' = Inch
                   'ft' = Foot
                   'yd' = Yard
                   'mi' = Mile
                   everything else is None

        """
        self.objects.set_raster_variables(frame=frame, quality=quality, units=units)

    def set_wipeout_variables(self, frame=0):
        """
        Set wipeout variables.

        Args:
            frame: 0 = do not show image frame; 1 = show image frame

        """
        self.objects.set_wipeout_variables(frame=frame)

    def add_underlay_def(self, filename, format='ext', name=None):
        """
        Add an underlay definition to the objects section.

        Args:
            format: file format as string pdf|dwf|dgn or ext=get format from filename extension
            name: underlay name, None for an auto-generated name

        """
        if self.dxfversion < 'AC1015':
            raise DXFVersionError('The UNDERLAY entity needs at least DXF version R2000 or later.')
        if format == 'ext':
            format = filename[-3:]
        return self.objects.add_underlay_def(filename, format, name)

    def add_xref_def(self, filename, name, flags=BLK_XREF | BLK_EXTERNAL):
        """
        Add an external reference (xref) definition to the blocks section.

        Add xref to a layout by `layout.add_blockref(name, insert=(0, 0))`.

        Args:
            filename: external reference filename
            name: name of the xref block
            flags: block flags

        """
        self.blocks.new(name=name, dxfattribs={
            'flags': flags,
            'xref_path': filename
        })

    def _get_encoding(self):
        codepage = self.header.get('$DWGCODEPAGE', 'ANSI_1252')
        return toencoding(codepage)

    @staticmethod
    def new(dxfversion='AC1009'):
        from .lldxf.const import versions_supported_by_new, acad_release_to_dxf_version

        dxfversion = dxfversion.upper()
        dxfversion = acad_release_to_dxf_version.get(dxfversion, dxfversion)  # translates 'R12' -> 'AC1009'
        if dxfversion not in versions_supported_by_new:
            raise DXFVersionError("Can not create DXF drawings, unsupported DXF version '{}'.".format(dxfversion))
        finder = TemplateLoader(options.template_dir)
        stream = finder.getstream(dxfversion.upper())
        try:
            dwg = Drawing.read(stream)
        finally:
            stream.close()
        dwg._setup_metadata()
        return dwg

    def _setup_metadata(self):
        self.header['$TDCREATE'] = juliandate(datetime.now())

    @staticmethod
    def read(stream: TextIO, legacy_mode: bool = False, dxfversion: str = None) -> 'Drawing':
        """ Open an existing drawing. """
        from .lldxf.tagger import low_level_tagger, tag_compiler

        tagger = low_level_tagger(stream)
        if legacy_mode:
            if dxfversion is not None and dxfversion <= 'AC1009':
                tagger = repair.filter_subclass_marker(tagger)
            tagger = repair.tag_reorder_layer(tagger)
        tagreader = tag_compiler(tagger)
        return Drawing(tagreader)

    def saveas(self, filename, encoding=None):
        self.filename = filename
        self.save(encoding=encoding)

    def save(self, encoding=None):
        # DXF R12, R2000, R2004 - ASCII encoding
        # DXF R2007 and newer - UTF-8 encoding
        if encoding is None:
            enc = 'utf-8' if self.dxfversion >= 'AC1021' else self.encoding
        else:  # override default encoding, for applications that handles encoding different than AutoCAD
            enc = encoding
        # in ASCII mode, unknown characters will be escaped as \U+nnnn unicode characters.
        with io.open(self.filename, mode='wt', encoding=enc, errors='dxfreplace') as fp:
            self.write(fp)

    def write(self, stream):
        from .lldxf.tagwriter import TagWriter
        if self.dxfversion == 'AC1009':
            handles = bool(self.header['$HANDLING'])
        else:
            handles = True
        if self.dxfversion > 'AC1009':
            self._register_required_classes()
            if self.dxfversion < 'AC1018':
                # remove unsupported group code 91
                repair.fix_classes(self)

        self._create_appids()
        self._update_metadata()
        tagwriter = TagWriter(stream, write_handles=handles)
        self.sections.write(tagwriter)

    def query(self, query='*'):
        """
        Entity query over all layouts and blocks.

        Excluding the OBJECTS section!

        Args:
            query: query string

        Returns: EntityQuery() container

        """
        return EntityQuery(self.chain_layouts_and_blocks(), query)

    def groupby(self, dxfattrib="", key=None):
        """
        Groups DXF entities of all layouts and blocks by an DXF attribute or a key function.

        Excluding the OBJECTS section!

        Args:
            dxfattrib: grouping DXF attribute like 'layer'
            key: key function, which accepts a DXFEntity as argument, returns grouping key of this entity or None for ignore
                 this object. Reason for ignoring: a queried DXF attribute is not supported by this entity

        Returns: dict

        """
        return groupby(self.chain_layouts_and_blocks(), dxfattrib, key)

    def cleanup(self, groups=True):
        """
        Cleanup drawing. Call it before saving the drawing but only if necessary, the process could take a while.

        Args:
            groups (bool): removes deleted and invalid entities from groups
        """
        if groups and self.groups is not None:
            self.groups.cleanup()

    def auditor(self):
        """
        Get auditor for this drawing.

        Returns:
            Auditor() object

        """
        from ezdxf.audit.auditor import Auditor
        return Auditor(self)

    def validate(self, print_report=True):
        """
        Simple way to run an audit process.

        Args:
            print_report: print report to stdout

        Returns: True if no errors occurred else False

        """
        auditor = self.auditor()
        result = list(auditor.filter_zero_pointers(auditor.run()))
        if len(result):
            if print_report:
                auditor.print_report()
            return False
        else:
            return True

    def update_class_instance_counters(self):
        if 'classes' in self.sections:
            self._register_required_classes()
            self.sections.classes.update_instance_counters()

    def _register_required_classes(self):
        register = self.sections.classes.register
        for dxftype in self.tracker.dxftypes:
            cls = self.dxffactory.get_wrapper_class(dxftype)
            if cls.CLASS is not None:
                register(cls.CLASS)

    def _update_metadata(self):
        now = datetime.now()
        self.header['$TDUPDATE'] = juliandate(now)
        self.header['$HANDSEED'] = str(self.entitydb.handles)
        self.header['$DWGCODEPAGE'] = tocodepage(self.encoding)
        self.reset_versionguid()

    def _create_appids(self):
        def create_appid_if_not_exist(name, flags=0):
            if name not in self.appids:
                self.appids.new(name, {'flags': flags})

        if 'HATCH' in self.tracker.dxftypes:
            create_appid_if_not_exist('HATCHBACKGROUNDCOLOR', 0)

    def reset_fingerprintguid(self):
        if self.dxfversion > 'AC1009':
            self.header['$FINGERPRINTGUID'] = guid()

    def reset_versionguid(self):
        if self.dxfversion > 'AC1009':
            self.header['$VERSIONGUID'] = guid()
