# SPDX-License-Identifier: LGPL-2.1-or-later

# ***************************************************************************
# *   Copyright (c) 2009, 2010 Yorik van Havre <yorik@uncreated.net>        *
# *   Copyright (c) 2009, 2010 Ken Cline <cline@frii.com>                   *
# *   Copyright (c) 2020 Eliud Cabrera Castillo <e.cabrera-castillo@tum.de> *
# *   Copyright (c) 2025 The FreeCAD Project Association                    *
# *                                                                         *
# *   This program is free software; you can redistribute it and/or modify  *
# *   it under the terms of the GNU Lesser General Public License (LGPL)    *
# *   as published by the Free Software Foundation; either version 2 of     *
# *   the License, or (at your option) any later version.                   *
# *   for detail see the LICENCE text file.                                 *
# *                                                                         *
# *   This program 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 program; if not, write to the Free Software   *
# *   Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  *
# *   USA                                                                   *
# *                                                                         *
# ***************************************************************************
"""Provides functions to downgrade objects by different methods.

See also the `upgrade` function.
"""
## @package downgrade
# \ingroup draftfunctions
# \brief Provides functions to downgrade objects by different methods.

## \addtogroup draftfunctions
# @{
import FreeCAD as App
from draftmake import make_copy
from draftutils import utils
from draftutils import params
from draftutils import gui_utils
from draftutils.groups import is_group
from draftutils.messages import _msg
from draftutils.translate import translate


def downgrade(objects, delete=False, force=None):
    """Downgrade the given objects.

    This is a counterpart to `upgrade`.

    Parameters
    ----------
    objects: Part::Feature or list
        A single object to downgrade or a list
        containing various such objects.

    delete: bool, optional
        It defaults to `False`.
        If it is `True`, the old objects are deleted, and only the resulting
        object is kept.

    force: str, optional
        It defaults to `None`.
        Its value can be used to force a certain method of downgrading.
        It can be any of: `'explode'`, `'shapify'`, `'subtr'`, `'splitFaces'`,
        `'cut2'`, `'getWire'`, `'splitWires'`, or `'splitCompounds'`.

    Returns
    -------
    tuple
        A tuple containing two lists, a list of new objects
        and a list of objects to be deleted.

    See Also
    --------
    upgrade
    """

    # definitions of actions to perform

    def explode(obj):
        """Explode a Draft block or array."""
        obj_pl = obj.Placement
        # block:
        if getattr(obj, "Components", []):
            delete_list.append(obj)
            for comp in obj.Components:
                # Objects in Components are in the same parent group as the block.
                comp.Placement = obj_pl.multiply(comp.Placement)
                comp.Visibility = True
            return True
        # array:
        if getattr(obj, "Base", None) is None:
            return False
        if not hasattr(obj, "PlacementList"):
            return False
        # Array must be added to delete_list before base. See can_be_deleted.
        delete_list.append(obj)
        base = obj.Base
        if not base.Visibility:
            # Delete base if it is not visible. The can_be_deleted
            # function will check if it is not used elsewhere.
            delete_list.append(base)
        new_list = []
        if getattr(obj, "ExpandArray", False):
            delete_list.extend(obj.ElementList)
            for lnk in obj.ElementList:
                newobj = make_copy.make_copy(base)
                newobj.Placement = obj_pl.multiply(lnk.Placement)
                newobj.Visibility = True
                new_list.append(newobj)
        else:
            for arr_pl in obj.PlacementList:
                newobj = make_copy.make_copy(base)
                newobj.Placement = obj_pl.multiply(arr_pl)
                newobj.Visibility = True
                new_list.append(newobj)
        add_to_parent(obj, new_list)
        add_list.extend(new_list)
        return True

    def cut2(objects):
        """Cut 2nd object from 1st."""
        newobj = doc.addObject("Part::Cut", "Cut")
        newobj.Base = objects[0]
        newobj.Tool = objects[1]
        format(objects[0], [newobj])
        add_to_parent(objects[0], [newobj])
        add_list.append(newobj)
        return True

    def splitCompounds(objects):
        """Split solids contained in compound objects into new objects."""
        result = False
        for obj in objects:
            if not obj.Shape.Solids:
                continue
            new_list = []
            for solid in obj.Shape.Solids:
                newobj = doc.addObject("Part::Feature", "Solid")
                newobj.Shape = solid
                new_list.append(newobj)
            format(obj, new_list)
            add_to_parent(obj, new_list)
            add_list.extend(new_list)
            delete_list.append(obj)
            result = True
        return result

    def splitFaces(objects):
        """Split faces contained in objects into new objects."""
        result = False
        preserveFaceColor = params.get_param("preserveFaceColor")
        preserveFaceNames = params.get_param("preserveFaceNames")
        for obj in objects:
            if not obj.Shape.Faces:
                continue
            new_list = []
            if App.GuiUp and preserveFaceColor and obj.ViewObject:
                colors = obj.ViewObject.DiffuseColor
            else:
                colors = None
            label = getattr(obj, "Label", "")
            for ind, face in enumerate(obj.Shape.Faces):
                newobj = doc.addObject("Part::Feature", "Face")
                newobj.Shape = face
                format(obj, [newobj])
                if App.GuiUp and preserveFaceColor and colors:
                    # At this point, some single-color objects might have just
                    # a single value in colors for all faces, so we handle that:
                    if ind < len(colors):
                        color = colors[ind]
                    else:
                        color = colors[0]
                    newobj.ViewObject.ShapeColor = color
                if preserveFaceNames:
                    newobj.Label = "{} {}".format(label, newobj.Label)
                new_list.append(newobj)
            add_to_parent(obj, new_list)
            add_list.extend(new_list)
            delete_list.append(obj)
            result = True
        return result

    def subtr(objects):
        """Subtract faces from the first one."""
        faces = []
        done_list = []
        for obj in objects:
            if obj.Shape.Faces:
                faces.extend(obj.Shape.Faces)
                done_list.append(obj)
        if not faces:
            return False
        main_face = faces.pop(0)
        for face in faces:
            main_face = main_face.cut(face)
        if main_face.isNull():
            return False
        newobj = doc.addObject("Part::Feature", "Subtraction")
        newobj.Shape = main_face
        format(done_list[0], [newobj])
        add_to_parent(done_list[0], [newobj])
        add_list.append(newobj)
        delete_list.extend(done_list)
        return True

    def getWire(obj):
        """Get the wire from a face object."""
        if not obj.Shape.Faces:
            return False
        new_list = []
        for wire in obj.Shape.Faces[0].Wires:
            newobj = doc.addObject("Part::Feature", "Wire")
            newobj.Shape = wire
            new_list.append(newobj)
        format(obj, new_list)
        add_to_parent(obj, new_list)
        add_list.extend(new_list)
        delete_list.append(obj)
        return True

    def splitWires(objects):
        """Split the wires contained in objects into edges."""
        result = False
        for obj in objects:
            if not obj.Shape.Edges:
                continue
            new_list = []
            for edge in obj.Shape.Edges:
                newobj = doc.addObject("Part::Feature", "Edge")
                newobj.Shape = edge
                new_list.append(newobj)
            format(obj, new_list)
            add_to_parent(obj, new_list)
            add_list.extend(new_list)
            delete_list.append(obj)
            result = True
        return result

    def _shapify(obj):
        """Wrapper for utils.shapify."""
        newobj = utils.shapify(obj, delete=False)
        if newobj:
            format(obj, [newobj])
            add_to_parent(obj, [newobj])
            add_list.append(newobj)
            delete_list.append(obj)
            return True
        return False

    # helper functions (same as in upgrade.py)

    def get_parent(obj):
        # Problem with obj.getParent():
        # https://github.com/FreeCAD/FreeCAD/issues/19600
        parent = obj.getParentGroup()
        if parent is not None:
            return parent
        return obj.getParentGeoFeatureGroup()

    def can_be_deleted(obj):
        if not obj.InList:
            return True
        for other in obj.InList:
            if is_group(other):
                continue
            if other.TypeId == "App::Part":
                continue
            return False
        return True

    def delete_object(obj):
        if utils.is_deleted(obj):
            return
        parent = get_parent(obj)
        if parent is not None and parent.TypeId == "PartDesign::Body":
            obj = parent
        if not can_be_deleted(obj):
            # Make obj invisible instead:
            obj.Visibility = False
            return
        if obj.TypeId == "PartDesign::Body":
            obj.removeObjectsFromDocument()
        doc.removeObject(obj.Name)

    def add_to_parent(obj, new_list):
        parent = get_parent(obj)
        if parent is None:
            if doc.getObject("Draft_Construction"):
                # This cludge is required because the make_* commands may
                # put new objects in the construction group.
                constr_group = doc.getObject("Draft_Construction")
                for newobj in new_list:
                    constr_group.removeObject(newobj)
            return
        if parent.TypeId == "PartDesign::Body":
            # We don't add to a PD Body. We process its placement and
            # add to its parent instead.
            for newobj in new_list:
                newobj.Placement = parent.Placement.multiply(newobj.Placement)
            add_to_parent(parent, new_list)
            return
        for newobj in new_list:
            # Using addObject is different from just changing the Group property.
            # With addObject the object will be added to the parent group, but if
            # that is a normal group, also to that group's parent GeoFeatureGroup,
            # if available.
            parent.addObject(newobj)

    def format(obj, new_list):
        for newobj in new_list:
            gui_utils.format_object(newobj, obj, ignore_construction=True)

    doc = App.ActiveDocument
    add_list = []
    delete_list = []
    result = False

    if not isinstance(objects, list):
        objects = [objects]
    if not objects:
        return add_list, delete_list

    # analyzing objects
    solids = []
    faces = []
    edges = []
    onlyedges = True
    parts = []

    for obj in objects:
        if hasattr(obj, "Shape"):
            for solid in obj.Shape.Solids:
                solids.append(False)
            for face in obj.Shape.Faces:
                faces.append(face)
            for edge in obj.Shape.Edges:
                edges.append(edge)
            if obj.Shape.ShapeType != "Edge":
                onlyedges = False
            parts.append(obj)
    objects = parts

    if not objects:
        result = False

    elif force:
        # functions that work on a single object:
        single_funcs = {"explode": explode, "getWire": getWire, "shapify": _shapify}
        # functions that work on multiple objects:
        multi_funcs = {
            "cut2": cut2,
            "splitCompounds": splitCompounds,
            "splitFaces": splitFaces,
            "splitWires": splitWires,
            "subtr": subtr,
        }
        if force in single_funcs:
            result = any([single_funcs[force](obj) for obj in objects])
        elif force in multi_funcs:
            result = multi_funcs[force](objects)
        else:
            _msg(translate("draft", "Downgrade: Unknown force method:") + " " + force)
            result = False

    else:
        parent = get_parent(objects[0])
        same_parent = True
        same_parent_type = getattr(parent, "TypeId", "")  # "" for global space.
        if len(objects) > 1:
            for obj in objects[1:]:
                if get_parent(obj) != parent:
                    same_parent = False
                    same_parent_type = None
                    break

        # we have a block, we explode it
        if len(objects) == 1 and utils.get_type(objects[0]) == "Block":
            result = explode(objects[0])
            if result:
                _msg(translate("draft", "Found 1 block: exploding it"))

        # we have an array, we explode it
        elif len(objects) == 1 and "Array" in utils.get_type(objects[0]):
            result = explode(objects[0])
            if result:
                _msg(translate("draft", "Found 1 array: exploding it"))

        # special case, we have one parametric object: we "de-parametrize" it
        elif (
            len(objects) == 1
            and hasattr(objects[0], "Shape")
            and (
                hasattr(objects[0], "Base")
                or hasattr(objects[0], "Profile")
                or hasattr(objects[0], "Sections")
            )
        ):
            result = _shapify(objects[0])
            if result:
                _msg(translate("draft", "Found 1 parametric object: breaking its dependencies"))

        # we have one multi-solids compound object: extract its solids
        elif len(objects) == 1 and hasattr(objects[0], "Shape") and len(solids) > 1:
            result = splitCompounds(objects)
            if result:
                _msg(translate("draft", "Found 1 multi-solids compound: exploding it"))

        # we have only 2 objects: cut 2nd from 1st
        elif len(objects) == 2 and same_parent and same_parent_type != "PartDesign::Body":
            result = cut2(objects)
            if result:
                _msg(translate("draft", "Found 2 objects: subtracting them"))

        elif len(faces) > 1:
            # one object with several faces: split it
            if len(objects) == 1:
                result = splitFaces(objects)
                if result:
                    _msg(translate("draft", "Found several faces: splitting them"))
            # several objects: remove all the faces from the first one
            elif same_parent and same_parent_type != "PartDesign::Body":
                result = subtr(objects)
                if result:
                    _msg(
                        translate(
                            "draft", "Found several faces: subtracting them from the first one"
                        )
                    )

        # only one face: we extract its wires
        elif len(faces) > 0:
            result = getWire(objects[0])
            if result:
                _msg(translate("draft", "Found 1 face: extracting its wires"))

        # no faces: split wire into single edges
        elif not onlyedges:
            result = splitWires(objects)
            if result:
                _msg(translate("draft", "Found only wires: extracting their edges"))

    # no result has been obtained
    if not result:
        _msg(translate("draft", "Unable to downgrade these objects"))

    if delete:
        for obj in delete_list:
            delete_object(obj)

    gui_utils.select(add_list)
    return add_list, delete_list


## @}
