# /***************************************************************************
# *   Copyright (c) 2016 Victor Titov (DeepSOIC) <vv.titov@gmail.com>       *
# *                                                                         *
# *   This file is part of the FreeCAD CAx development system.              *
# *                                                                         *
# *   This library is free software; you can redistribute it and/or         *
# *   modify it under the terms of the GNU Library General Public           *
# *   License as published by the Free Software Foundation; either          *
# *   version 2 of the License, or (at your option) any later version.      *
# *                                                                         *
# *   This library  is distributed in the hope that it will be useful,      *
# *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
# *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
# *   GNU Library General Public License for more details.                  *
# *                                                                         *
# *   You should have received a copy of the GNU Library General Public     *
# *   License along with this library; see the file COPYING.LIB. If not,    *
# *   write to the Free Software Foundation, Inc., 59 Temple Place,         *
# *   Suite 330, Boston, MA  02111-1307, USA                                *
# *                                                                         *
# ***************************************************************************/

# module is named mTempoVis, because Show.TimpoVis exposes the class as its member, and hides the module TempoVis.py

from . import Containers

from . import TVStack

import FreeCAD as App

if App.GuiUp:
    import FreeCADGui as Gui
Wrn = lambda msg: App.Console.PrintWarning(msg + "\n")
Err = lambda msg: App.Console.PrintError(msg + "\n")
Log = lambda msg: App.Console.PrintLog(msg + "\n")

from copy import copy

S_EMPTY = 0  # TV is initialized, but no changes were done through it
S_ACTIVE = 1  # TV has something to be undone
S_RESTORED = 2  # TV has been restored
S_INTERNAL = 3  # TV instance is being used by another TV instance as a redo data storage


def _printTraceback(err):
    import sys

    if err is sys.exc_info()[1]:
        import traceback

        tb = traceback.format_exc()
        Log(tb)


class MAINSTACK(object):
    """it's just a default value definition for TV constructor"""

    pass


class JUST_SAVE(object):
    '''it's just a default value meaning "save current scene value but don't modify anything"'''

    pass


class TempoVis(object):
    """TempoVis - helper object to save visibilities of objects before doing
    some GUI editing, hiding or showing relevant stuff during edit, and
    then restoring all visibilities after editing.

    Constructors:
    TempoVis(document, stack = MAINSTACK, **kwargs): creates a new TempoVis.

    document: required. Objects not belonging to the document can't be modified via TempoVis.

    stack: optional. Which stack to insert this new TV into. Can be:
    a TVStack instance (then, the new TV is added to the top of the stack),
    MAINSTACK special value (a global stack for the document will be used), or
    None (then, the TV is not in any stack, and can be manually instertd into one if desired).

    Any additional keyword args are assigned as attributes. You can use it to immediately set a tag, for example.
    """

    document = None
    stack = None  # reference to stack this TV is in

    data = None  # dict. key = ("class_id","key"), value = instance of SceneDetail
    data_requested = None  # same as data, but stores (wanted) values passed to modify()

    state = S_EMPTY

    tag = ""  # stores any user-defined string for identification purposes

    def _init_attrs(self):
        """initialize member variables to empty values (needed because we can't use mutable initial values when initializing member variables in class definition)"""
        self.data = {}
        self.data_requested = {}

    # <core interface>
    def __init__(self, document, stack=MAINSTACK, **kwargs):
        self._init_attrs()
        self.document = document

        if stack is MAINSTACK:
            stack = TVStack.mainStack(document)

        if stack is None:
            pass
        else:
            stack.insert(self)

        for key, val in kwargs.items():
            setattr(self, key, val)

    def __del__(self):
        if self.state == S_ACTIVE:
            self.restore(ultimate=True)

    def has(self, detail):
        """has(self, detail): returns True if this TV has this detail value saved.
        example: tv.has(VProperty(obj, "Visibility"))"""
        return detail.full_key in self.data

    def stored_val(self, detail):
        """stored_val(self, detail): returns value of detail remembered by this TV. If not, raises KeyError."""
        return self.data[detail.full_key].data

    def save(self, detail, mild_restore=False):
        """save(detail, mild_restore = False):saves the scene detail to be restored.
        The detail is saved only once; repeated calls are ignored.
        mild_restore: internal, do not use."""
        self._change()
        if not detail.full_key in self.data:
            # not saved yet
            tv1, curr = self._value_after(detail, query_scene=True)
            self.data[detail.full_key] = copy(curr)
            self.data[detail.full_key].mild_restore = mild_restore
        else:
            # saved already. Change restore policy, if necessary.
            stored_dt = self.data[detail.full_key]
            if not mild_restore:
                stored_dt.mild_restore = False

    def modify(self, detail, mild_restore=None):
        """modify(detail, mild_restore = True): modifies scene detail through this TV.
        The value is provided as an instance of SceneDetail implementation.
        The procedure takes care to account for the stack - that is, if in a TV applied
        later than this one this detail was changed too, the value saved therein is altered,
        rather than applied to the scene.

        mild_restore: if True, when restoring later, checks if the value was changed
        by user after last call to modify(), and doesn't restore if it was changed.

        Example: tv.modify(VProperty(obj, "Visibility", True))"""

        self._change()

        if mild_restore is not None:
            detail.mild_restore = mild_restore

        # save current
        self.save(detail, detail.mild_restore)

        # apply
        tv1, curr = self._value_after(detail)
        if tv1 is not None:
            tv1.data[detail.full_key].data = detail.data
        else:
            detail.apply_data(detail.data)

        # and record.
        if detail.mild_restore:
            self.data_requested[detail.full_key] = copy(detail)

    def restoreDetail(self, detail, ultimate=False):
        """restoreDetail(detail, ultimate = False): restores a specific scene detail.
        ultimate: if true, the saved value is cleaned out.
        If the detail is not found, nothing is done.
        """
        if not self.has(detail):
            return
        self._restore_detail(detail)
        if ultimate:
            self.forgetDetail(detail)

    def forgetDetail(self, detail):
        """forgetDetail(detail): ditches a saved detail value, making the change done through this TV permanent."""
        self.data.pop(detail.full_key, None)
        self.data_requested.pop(detail.full_key, None)

    def forget(self):
        """forget(self): clears this TV, making all changes done through it permanent.
        Also, withdraws the TV from the stack."""
        self.state = S_EMPTY
        self.data = {}
        if self.is_in_stack:
            self.stack.withdraw(self)

    def restore(self, ultimate=True):
        """restore(ultimate = True): undoes all changes done through this tempovis / restores saved scene details.
        ultimate: if true, the saved values are cleaned out, and the TV is withdrawn from
        the stack. If false, the TV will still remember stuff, and restore can be called again.
        """
        if self.state == S_RESTORED:
            return

        if self.state != S_INTERNAL and ultimate:
            self.state = S_RESTORED

        for key, detail in self.data.items():
            try:
                self._restoreDetail(detail)
            except Exception as err:
                Err(
                    "TempoVis.restore: failed to restore detail {key}: {err}".format(
                        key=key, err=str(err)
                    )
                )
                _printTraceback(err)
        if ultimate:
            self.data = {}
            if self.is_in_stack:
                self.stack.withdraw(self)

    # </core interface>

    # <stack interface>
    def _inserted(self, stack, index):
        """called when this tv is inserted into a stack"""
        self.stack = stack

    def _withdrawn(self, stack, index):
        """called when this tv is withdrawn from a stack"""
        self.stack = None

    @property
    def is_in_stack(self):
        return self.stack is not None

    # </stack interface>

    # <convenience functions>
    def modifyVPProperty(self, doc_obj_or_list, prop_names, new_value=JUST_SAVE, mild_restore=None):
        """modifyVPProperty(doc_obj_or_list, prop_names, new_value = JUST_SAVE, mild_restore = None): modifies
        prop_name property of ViewProvider of doc_obj_or_list, and remembers
        original value of the property. Original values will be restored upon
        TempoVis deletion, or call to restore().

        mild_restore: test if user changed the value manually when restoring the TV."""

        if self.state == S_RESTORED:
            Wrn("Attempting to use a TV that has been restored. There must be a problem with code.")
            return

        if not hasattr(doc_obj_or_list, "__iter__"):
            doc_obj_or_list = [doc_obj_or_list]
        if not isinstance(prop_names, (list, tuple)):
            prop_names = [prop_names]
        for doc_obj in doc_obj_or_list:
            for prop_name in prop_names:
                if not hasattr(doc_obj.ViewObject, prop_name):
                    Wrn(
                        "TempoVis: object {obj} has no attribute {attr}. Skipped.".format(
                            obj=doc_obj.Name, attr=prop_name
                        )
                    )
                    continue

                # Because the introduction of external objects, we shall now
                # accept objects from all opened documents.
                #
                #  if doc_obj.Document is not self.document:  #ignore objects from other documents
                #      raise ValueError("Document object to be modified does not belong to document TempoVis was made for.")
                from .SceneDetails.VProperty import VProperty

                if new_value is JUST_SAVE:
                    if mild_restore:
                        Wrn(
                            "TempoVis: can't just save a value for mild restore. Saving for hard restore."
                        )
                    self.save(VProperty(doc_obj, prop_name, new_value))
                else:
                    self.modify(VProperty(doc_obj, prop_name, new_value), mild_restore)

    def restoreVPProperty(self, doc_obj_or_list, prop_names):
        """restoreVPProperty(doc_obj_or_list, prop_name, new_value): restores specific property changes."""
        from .SceneDetails.VProperty import VProperty

        if not hasattr(doc_obj_or_list, "__iter__"):
            doc_obj_or_list = [doc_obj_or_list]
        if not isinstance(prop_names, (tuple, list)):
            prop_names = [prop_names]
        for doc_obj in doc_obj_or_list:
            for prop_name in prop_names:
                try:
                    self.restoreDetail(VProperty(doc_obj, prop_name))
                except Exception as err:
                    Err(
                        "TempoVis.restore: failed to restore detail {key}: {err}".format(
                            key=key, err=str(err)
                        )
                    )
                    _printTraceback(err)

    def saveBodyVisibleFeature(self, doc_obj_or_list):
        """saveBodyVisibleFeature(self, doc_obj_or_list): saves Visibility of currently
        visible feature, for every body of PartDesign features in the provided list."""
        if not hasattr(doc_obj_or_list, "__iter__"):
            doc_obj_or_list = [doc_obj_or_list]
        objs = []
        bodies = set()
        for obj in doc_obj_or_list:
            body = getattr(obj, "_Body", None)
            if not body or body in bodies:
                continue
            bodies.add(body)
            feature = getattr(body, "VisibleFeature", None)
            if feature:
                objs.append(feature)
        self.modifyVPProperty(objs, "Visibility", JUST_SAVE)
        return objs

    def show(self, doc_obj_or_list, links_too=True, mild_restore=None):
        """show(doc_obj_or_list, links_too = True): shows objects (sets their Visibility to True).
        doc_obj_or_list can be a document object, or a list of document objects.
        If links_too is True, all Links of the objects are also hidden, by setting LinkVisibility attribute of each object.
        """
        doc_obj_or_list = self._3D_objects(doc_obj_or_list)
        self.saveBodyVisibleFeature(
            doc_obj_or_list
        )  # fix implicit hiding of other features by PartDesign not being recorded to TV
        self.modifyVPProperty(doc_obj_or_list, "Visibility", True, mild_restore)
        if links_too:
            self.modifyVPProperty(doc_obj_or_list, "LinkVisibility", True, mild_restore)

    def hide(self, doc_obj_or_list, links_too=True, mild_restore=None):
        """hide(doc_obj_or_list): hides objects (sets their Visibility to False). doc_obj_or_list can be a document object, or a list of document objects"""
        doc_obj_or_list = self._3D_objects(doc_obj_or_list)
        # no need to saveBodyVisibleFeature here, as no implicit showing will happen
        self.modifyVPProperty(doc_obj_or_list, "Visibility", False, mild_restore)
        if links_too:
            self.modifyVPProperty(doc_obj_or_list, "LinkVisibility", False, mild_restore)

    def get_all_dependent(self, doc_obj, subname=None):
        """get_all_dependent(doc_obj, subname = None): gets all objects that depend on doc_obj. Containers and Links (if subname) required for visibility of the object are excluded from the list."""
        from . import Containers
        from .Containers import isAContainer
        from .DepGraphTools import getAllDependencies, getAllDependent

        if subname:
            # a link-path was provided. doc_obj has nothing to do with the object we want
            # to collect dependencies from. So, replace it with the one pointed by link-path.
            cnt_chain = doc_obj.getSubObjectList(subname)
            doc_obj = cnt_chain[-1].getLinkedObject()
            # cnt_chain can either end with the object (e.g. if a sketch is in a part, and
            # a link is to a part), or it may be a Link object (if we have a straight or
            # even nested Link to the sketch).
            #
            # I don't know why do we need that isAContainer check here, but I'm leaving it,
            # realthunder must be knowing his business --DeepSOIC
            cnt_chain = [
                o for o in cnt_chain if o == cnt_chain[-1] or isAContainer(o, links_too=True)
            ]
        else:
            cnt_chain = Containers.ContainerChain(doc_obj)
        return [o for o in getAllDependent(doc_obj) if not o in cnt_chain]

    def hide_all_dependent(self, doc_obj):
        """hide_all_dependent(doc_obj): hides all objects that depend on doc_obj. Groups, Parts and Bodies are not hidden by this."""
        self.hide(self._3D_objects(self.get_all_dependent(doc_obj)))

    def show_all_dependent(self, doc_obj):
        """show_all_dependent(doc_obj): shows all objects that depend on doc_obj. This method is probably useless."""
        from .DepGraphTools import getAllDependencies, getAllDependent

        self.show(self._3D_objects(getAllDependent(doc_obj)))

    def restore_all_dependent(self, doc_obj):
        """show_all_dependent(doc_obj): restores original visibilities of all dependent objects."""
        from .DepGraphTools import getAllDependencies, getAllDependent

        self.restoreVPProperty(getAllDependent(doc_obj), ("Visibility", "LinkVisibility"))

    def hide_all_dependencies(self, doc_obj):
        """hide_all_dependencies(doc_obj): hides all objects that doc_obj depends on (directly and indirectly)."""
        from .DepGraphTools import getAllDependencies, getAllDependent

        self.hide(self._3D_objects(getAllDependencies(doc_obj)))

    def show_all_dependencies(self, doc_obj):
        """show_all_dependencies(doc_obj): shows all objects that doc_obj depends on (directly and indirectly). This method is probably useless."""
        from .DepGraphTools import getAllDependencies, getAllDependent

        self.show(self._3D_objects(getAllDependencies(doc_obj)))

    def saveCamera(self, vw=None):
        self._change()
        from .SceneDetails.Camera import Camera

        self.save(Camera(self.document))

    def restoreCamera(self, ultimate=False):
        from .SceneDetails.Camera import Camera

        dt = Camera(self.document)
        self.restoreDetail(dt, ultimate)

    def setUnpickable(
        self, doc_obj_or_list, actual_pick_style=2
    ):  # 2 is coin.SoPickStyle.UNPICKABLE
        """setUnpickable(doc_obj_or_list, actual_pick_style = 2): sets object unpickable (transparent to clicks).
        doc_obj_or_list: object or list of objects to alter (App)
        actual_pick_style: optional parameter, specifying the actual pick style:
        0 = regular, 1 = bounding box, 2 (default) = unpickable.

        Implementation detail: uses SoPickStyle node. If viewprovider already has a node
        of this type as direct child, one is used. Otherwise, new one is created and
        inserted as the very first node, and remains there even after restore()/deleting
        tempovis."""

        from .SceneDetails.Pickability import Pickability
        from .ShowUtils import is3DObject

        if not hasattr(doc_obj_or_list, "__iter__"):
            doc_obj_or_list = [doc_obj_or_list]
        for doc_obj in doc_obj_or_list:
            if not is3DObject(doc_obj):
                continue
            dt = Pickability(doc_obj, actual_pick_style)
            self.modify(dt)

    def clipPlane(self, doc_obj_or_list, enable, placement, offset=0.02):
        """clipPlane(doc_obj_or_list, enable, placement, offset): slices off the object with a clipping plane.
        doc_obj_or_list: object or list of objects to alter (App)
        enable: True if you want clipping, False if you want to remove clipping:
        placement: XY plane of local coordinates of the placement is the clipping plane. The placement must be in document's global coordinate system.
        offset: shifts the plane. Positive offset reveals more of the object.

        Implementation detail: uses SoClipPlane node. If viewprovider already has a node
        of this type as direct child, one is used. Otherwise, new one is created and
        inserted as the very first node. The node is left, but disabled when tempovis is restoring.
        """

        from .SceneDetails.ObjectClipPlane import ObjectClipPlane
        from .ShowUtils import is3DObject

        if not hasattr(doc_obj_or_list, "__iter__"):
            doc_obj_or_list = [doc_obj_or_list]
        for doc_obj in doc_obj_or_list:
            if not is3DObject(doc_obj):
                continue
            dt = ObjectClipPlane(doc_obj, enable, placement, offset)
            self.modify(dt)

    @staticmethod
    def allVisibleObjects(aroundObject):
        """allVisibleObjects(aroundObject): returns list of objects that have to be toggled invisible for only aroundObject to remain.
        If a whole container can be made invisible, it is returned, instead of its child objects."""
        from .ShowUtils import is3DObject
        from . import Containers

        chain = Containers.VisGroupChain(aroundObject)
        result = []
        for i in range(len(chain)):
            cnt = chain[i]
            cnt_next = chain[i + 1] if i + 1 < len(chain) else aroundObject
            container = Containers.Container(cnt)
            for obj in container.getVisGroupChildren():
                if not is3DObject(obj):
                    continue
                if obj is not cnt_next:
                    if container.isChildVisible(obj):
                        result.append(obj)
        return result

    def sketchClipPlane(self, sketch, enable=None, reverted=False):
        """sketchClipPlane(sketch, enable = None): Clips all objects by plane of sketch.
        If enable argument is omitted, calling the routine repeatedly will toggle clipping plane."""

        from .SceneDetails.ClipPlane import ClipPlane

        editDoc = Gui.editDocument()
        if editDoc is None:
            doc = sketch.Document
            pla = sketch.getGlobalPlacement()
        else:
            doc = editDoc.Document
            pla = App.Placement(editDoc.EditingTransform)
        toggle = {False: 0, True: 1, None: -1}[enable]

        if reverted:
            pla = pla * App.Rotation(0, 1, 0, 0)

        if enable:  # clip plane shall be disabled so new placement can be applied
            self.modify(ClipPlane(doc, 0))

        self.modify(ClipPlane(doc, toggle, pla, 0.001))
        sketch.ViewObject.SectionView = (
            enable if enable is not None else not sketch.ViewObject.SectionView
        )

    def activateWorkbench(self, wb_name):
        from .SceneDetails.Workbench import Workbench

        self.modify(Workbench(wb_name))

    # </convenience functions>

    # <internals>
    def _restoreDetail(self, detail):
        p = self.data[detail.full_key]
        tv1, curr = self._value_after(detail, query_scene=p.mild_restore)
        if p.mild_restore:
            if self.data_requested[detail.full_key] != curr:
                # the value on the scene doesn't match what was requested through TV. User probably changed it. We don't want to mess it up.
                self._purge_milds(detail)
                return
        if tv1 is None:
            # no other TV has changed this detail later, apply to the scene
            detail.apply_data(p.data)
        else:
            # modify saved detail of higher TV
            tv1.data[detail.full_key].data = p.data

    def _purge_milds(self, detail):
        """_purge_milds(detail): wipes out detail from earlier TVs if the detail is mild-restore."""
        if not self.is_in_stack:
            return
        seq_before, seq_after = self.stack.getSplitSequence(self)
        for tv in reversed(seq_before):
            if tv.has(detail):
                if tv.data[detail.full_key].mild_restore:
                    tv.forgetDetail(detail)
                else:
                    # hard-restoring value encountered, stop
                    break

    def _change(self):
        """to be called whenever anything is done that is to be restored later."""
        if self.state == S_EMPTY:
            self.state = S_ACTIVE
        if self.state == S_RESTORED:
            Wrn("Attempting to use a TV that has been restored. There must be a problem with code.")
        self.tv_redo = None

    def _value_after(self, detail, query_scene=False):
        """_value_current(detail): returns (tv, detail1). SceneDetail instance holds "current" value of
        scene detail (current from the context of this TV; i.e. either the current scene
        status, or the saved state from upper TVs).
        If no upper TV has saved the detail value, returns either (None, None), or
        (None, detail1) if query_scene is True, where detail1 holds value from the scene."""

        def scene_value():
            if query_scene:
                cpy = copy(detail)
                cpy.data = cpy.scene_value()
                return (None, cpy)
            else:
                return (None, None)

        if self.is_in_stack:
            va = self.stack.value_after(self, detail)
            if va is None:
                return scene_value()
            else:
                return va
        else:
            return scene_value()

    def _3D_objects(self, doc_obj_or_list):
        """_3D_objects(doc_obj_or_list): returns list of objects that are in 3d view."""
        from .ShowUtils import is3DObject

        if not hasattr(doc_obj_or_list, "__iter__"):
            doc_obj_or_list = [doc_obj_or_list]

        return [obj for obj in doc_obj_or_list if is3DObject(obj)]

    def dumps(self):
        return None

    def loads(self, state):
        self._init_attrs()
