# Copyright (c) 2011-2020, Manfred Moitzi
# License: MIT License
from typing import TYPE_CHECKING, Dict, Iterable, List, cast, Optional
import logging
from ezdxf.lldxf.const import DXFKeyError, DXFValueError, DXFInternalEzdxfError
from ezdxf.lldxf.const import (
    MODEL_SPACE_R2000, PAPER_SPACE_R2000,
    TMP_PAPER_SPACE_NAME,
)
from ezdxf.lldxf.validator import is_valid_table_name
from .layout import Layout, Modelspace, Paperspace
from ezdxf.entities import DXFEntity

if TYPE_CHECKING:
    from ezdxf.eztypes import Dictionary, Drawing, Auditor

logger = logging.getLogger('ezdxf')


def key(name: str) -> str:
    """ AutoCAD uses case insensitive layout names, but stores the name case
    sensitive. """
    return name.upper()


MODEL = key('Model')


class Layouts:
    def __init__(self, doc: 'Drawing'):
        """ Default constructor. (internal API) """
        self.doc = doc
        # Store layout names in normalized form: key(name)
        self._layouts: Dict[str, Layout] = {}
        # key: layout name as original case sensitive string; value: DXFLayout()
        self._dxf_layouts: 'Dictionary' = cast('Dictionary',
                                               self.doc.rootdict['ACAD_LAYOUT'])

    @classmethod
    def setup(cls, doc: 'Drawing'):
        """ Constructor from scratch. (internal API) """
        layouts = Layouts(doc)
        layouts.setup_modelspace()
        layouts.setup_paperspace()
        return layouts

    def __len__(self) -> int:
        """ Returns count of existing layouts, including the modelspace
        layout. """
        return len(self._layouts)

    def __contains__(self, name: str) -> bool:
        """ Returns ``True`` if layout `name` exist. """
        assert isinstance(name, str), type(str)
        return key(name) in self._layouts

    def __iter__(self) -> Iterable['Layout']:
        """ Returns iterable of all layouts as :class:`~ezdxf.layouts.Layout`
        objects, including the modelspace layout.
        """
        return iter(self._layouts.values())

    def _add_layout(self, name: str, layout: Layout):
        layout.dxf.name = name
        self._layouts[key(name)] = layout
        self._dxf_layouts[name] = layout.dxf_layout

    def _discard(self, layout: 'Layout'):
        name = layout.name
        self._dxf_layouts.discard(name)
        del self._layouts[key(name)]

    def setup_modelspace(self):
        """ Modelspace setup. (internal API) """
        self._new_special(Modelspace, 'Model', MODEL_SPACE_R2000,
                          dxfattribs={'taborder': 0})

    def setup_paperspace(self):
        """ First layout setup. (internal API) """
        self._new_special(Paperspace, 'Layout1', PAPER_SPACE_R2000,
                          dxfattribs={'taborder': 1})

    def _new_special(self, cls, name: str, block_name: str,
                     dxfattribs: dict) -> 'Layout':
        if name in self._layouts:
            raise DXFValueError(f'Layout "{name}" already exists')
        dxfattribs['owner'] = self._dxf_layouts.dxf.handle
        layout = cls.new(name, block_name, self.doc, dxfattribs=dxfattribs)
        self._add_layout(name, layout)
        return layout

    def unique_paperspace_name(self) -> str:
        """ Returns a unique paperspace name. (internal API)"""
        blocks = self.doc.blocks
        count = 0
        while "*Paper_Space%d" % count in blocks:
            count += 1
        return "*Paper_Space%d" % count

    def new(self, name: str, dxfattribs: dict = None) -> Paperspace:
        """ Returns a new :class:`~ezdxf.layouts.Paperspace` layout.

        Args:
            name: layout name as shown in tabs in :term:`CAD` applications
            dxfattribs: additional DXF attributes for the
                :class:`~ezdxf.entities.layout.DXFLayout` entity

        Raises:
            DXFValueError: Invalid characters in layout name.
            DXFValueError: Layout `name` already exist.

        """
        assert isinstance(name, str), type(str)
        if not is_valid_table_name(name):
            raise DXFValueError('Layout name contains invalid characters.')

        if name in self:
            raise DXFValueError(f'Layout "{name}" already exist.')

        dxfattribs = dict(dxfattribs or {})  # copy attribs
        dxfattribs['owner'] = self._dxf_layouts.dxf.handle
        dxfattribs.setdefault('taborder', len(self._layouts) + 1)
        block_name = self.unique_paperspace_name()
        layout = Paperspace.new(name, block_name, self.doc,
                                dxfattribs=dxfattribs)
        self._add_layout(name, layout)
        return layout

    @classmethod
    def load(cls, doc: 'Drawing') -> 'Layouts':
        """ Constructor if loading from file. (internal API) """
        layouts = cls(doc)
        layouts.setup_from_rootdict()

        # DXF R12: block/block_record for *Model_Space and *Paper_Space
        # already exist:
        if len(layouts) < 2:  # restore missing DXF Layouts
            layouts.restore('Model', MODEL_SPACE_R2000, taborder=0)
            layouts.restore('Layout1', PAPER_SPACE_R2000, taborder=1)
        return layouts

    def restore(self, name: str, block_record_name: str, taborder: int) -> None:
        """ Restore layout from block if DXFLayout does not exist.
        (internal API) """
        if name in self:
            return
        block_layout = self.doc.blocks.get(block_record_name)
        self._new_from_block_layout(name, block_layout, taborder)

    def _new_from_block_layout(self, name, block_layout,
                               taborder: int) -> 'Layout':
        dxfattribs = {
            'owner': self._dxf_layouts.dxf.handle,
            'name': name,
            'block_record_handle': block_layout.block_record_handle,
            'taborder': taborder,
        }
        dxf_layout = cast('DXFLayout', self.doc.objects.new_entity(
            'LAYOUT', dxfattribs=dxfattribs))
        if key(name) == MODEL:
            layout = Modelspace.load(dxf_layout, self.doc)
        else:
            layout = Paperspace.load(dxf_layout, self.doc)
        self._add_layout(name, layout)
        return layout

    def setup_from_rootdict(self) -> None:
        """ Setup layout manger from root dictionary. (internal API) """
        for name, dxf_layout in self._dxf_layouts.items():
            if key(name) == MODEL:
                layout = Modelspace(dxf_layout, self.doc)
            else:
                layout = Paperspace(dxf_layout, self.doc)
            # assert name == layout.dxf.name
            self._layouts[key(name)] = layout

    def modelspace(self) -> Modelspace:
        """ Returns the :class:`~ezdxf.layouts.Modelspace` layout. """
        return cast(Modelspace, self.get('Model'))

    def names(self) -> List[str]:
        """ Returns a list of all layout names, all names in original case
        sensitive form. """
        return [layout.name for layout in self._layouts.values()]

    def get(self, name: Optional[str]) -> 'Layout':
        """ Returns :class:`~ezdxf.layouts.Layout` by `name`, case insensitive
        "Model" == "MODEL".

        Args:
            name: layout name as shown in tab, e.g. ``'Model'`` for modelspace

        """
        name = name or self.names_in_taborder()[1]  # first paperspace layout
        return self._layouts[key(name)]

    def rename(self, old_name: str, new_name: str) -> None:
        """ Rename a layout from `old_name` to `new_name`.
        Can not rename layout ``'Model'`` and the new name of a layout must
        not exist.

        Args:
            old_name: actual layout name, case insensitive
            new_name: new layout name, case insensitive

        Raises:
            DXFValueError: try to rename ``'Model'``
            DXFValueError: Layout `new_name` already exist.

        """
        assert isinstance(old_name, str), type(old_name)
        assert isinstance(new_name, str), type(new_name)
        if key(old_name) == MODEL:
            raise DXFValueError('Can not rename model space.')
        if new_name in self:
            raise DXFValueError(f'Layout "{new_name}" already exist.')
        if old_name not in self:
            raise DXFValueError(f'Layout "{old_name}" does not exist.')

        layout = self.get(old_name)
        self._discard(layout)
        layout.rename(new_name)
        self._add_layout(new_name, layout)

    def names_in_taborder(self) -> List[str]:
        """ Returns all layout names in tab order as shown in :term:`CAD`
        applications. """
        names = [(layout.dxf.taborder, layout.name) for layout in
                 self._layouts.values()]
        return [name for order, name in sorted(names)]

    def get_layout_for_entity(self, entity: 'DXFEntity') -> 'Layout':
        """ Returns the owner layout for a DXF `entity`. """
        owner = entity.dxf.owner
        if owner is None:
            raise DXFKeyError('No associated layout, owner is None.')
        return self.get_layout_by_key(entity.dxf.owner)

    def get_layout_by_key(self, layout_key: str) -> 'Layout':
        """ Returns a layout by its `layout_key`. (internal API) """
        assert isinstance(layout_key, str), type(layout_key)
        try:
            block_record = self.doc.entitydb[layout_key]
            dxf_layout = self.doc.entitydb[block_record.dxf.layout]
        except KeyError:
            raise DXFKeyError(f'Layout with key "{layout_key}" does not exist.')
        return self.get(dxf_layout.dxf.name)

    def get_active_layout_key(self):
        """ Returns layout kay for the active paperspace layout.
        (internal API) """
        active_layout_block_record = self.doc.block_records.get(
            PAPER_SPACE_R2000)
        return active_layout_block_record.dxf.handle

    def set_active_layout(self, name: str) -> None:
        """ Set layout `name` as active paperspace layout. """
        assert isinstance(name, str), type(name)
        if key(name) == MODEL:  # reserved layout name
            raise DXFValueError('Can not set model space as active layout')
        # raises KeyError if layout 'name' does not exist
        new_active_layout = self.get(name)
        old_active_layout_key = self.get_active_layout_key()
        if old_active_layout_key == new_active_layout.layout_key:
            return  # layout 'name' is already the active layout

        blocks = self.doc.blocks
        new_active_paper_space_name = new_active_layout.block_record_name

        blocks.rename_block(PAPER_SPACE_R2000, TMP_PAPER_SPACE_NAME)
        blocks.rename_block(new_active_paper_space_name, PAPER_SPACE_R2000)
        blocks.rename_block(TMP_PAPER_SPACE_NAME, new_active_paper_space_name)

    def delete(self, name: str) -> None:
        """ Delete layout `name` and destroy all entities in that layout.

        Args:
            name (str): layout name as shown in tabs

        Raises:
            DXFKeyError: if layout `name` do not exists
            DXFValueError: deleting modelspace layout is not possible
            DXFValueError: deleting last paperspace layout is not possible

        """
        assert isinstance(name, str), type(name)
        if key(name) == MODEL:
            raise DXFValueError("Can not delete modelspace layout.")

        layout = self.get(name)
        if len(self) < 3:
            raise DXFValueError("Can not delete last paperspace layout.")
        if layout.layout_key == self.get_active_layout_key():
            # Layout `name` is the active layout:
            for layout_name in self._layouts:
                # Set any other paperspace layout as active layout
                if layout_name not in (key(name), MODEL):
                    self.set_active_layout(layout_name)
                    break
        self._discard(layout)
        layout.destroy()

    def active_layout(self) -> Paperspace:
        """ Returns the active paperspace layout. """
        for layout in self:
            if layout.is_active_paperspace:
                return cast(Paperspace, layout)
        raise DXFInternalEzdxfError('No active paperspace layout found.')

    def audit(self, auditor: 'Auditor'):
        from ezdxf.audit import AuditError
        doc = auditor.doc

        # Find/remove orphaned LAYOUT objects:
        layouts = (o for o in doc.objects if o.dxftype() == 'LAYOUT')
        for layout in layouts:
            name = layout.dxf.get('name')
            if name not in self:
                auditor.fixed_error(
                    code=AuditError.ORPHANED_LAYOUT_ENTITY,
                    message=f'Removed orphaned {str(layout)} "{name}"'
                )
                doc.objects.delete_entity(layout)

        # Find/remove orphaned paperspace BLOCK_RECORDS named: *Paper_Space...
        psp_br_handles = {
            br.dxf.handle for br in doc.block_records if
            br.dxf.name.lower().startswith('*paper_space')
        }
        psp_layout_br_handles = {
            layout.dxf.block_record_handle for layout in
            self._layouts.values() if key(layout.name) != MODEL
        }
        mismatch = psp_br_handles.difference(psp_layout_br_handles)
        if len(mismatch):
            for handle in mismatch:
                br = doc.entitydb.get(handle)
                name = br.dxf.get('name')
                auditor.fixed_error(
                    code=AuditError.ORPHANED_PAPER_SPACE_BLOCK_RECORD_ENTITY,
                    message=f'Removed orphaned layout {str(br)} "{name}"'
                )
                if name in doc.blocks:
                    doc.blocks.delete_block(name)
                else:
                    doc.block_records.remove(name)
