# 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 upgrade objects by different methods.

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

import math
import re
import lazy_loader.lazy_loader as lz

import FreeCAD as App
from draftfunctions import draftify
from draftgeoutils.geometry import is_straight_line
from draftmake import make_block
from draftmake import make_wire
from draftutils import gui_utils
from draftutils import params
from draftutils import utils
from draftutils.groups import is_group
from draftutils.messages import _msg
from draftutils.translate import translate

# Delay import of module until first use because it is heavy
Part = lz.LazyLoader("Part", globals(), "Part")
DraftGeomUtils = lz.LazyLoader("DraftGeomUtils", globals(), "DraftGeomUtils")
Arch = lz.LazyLoader("Arch", globals(), "Arch")

_DEBUG = False

## \addtogroup draftfunctions
# @{


def upgrade(objects, delete=False, force=None):
    """Upgrade the given objects.

    This is a counterpart to `downgrade`.

    Parameters
    ----------
    objects: Part::Feature or list
        A single object to upgrade 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 upgrading.
        It can be any of: `'makeCompound'`, `'closeGroupWires'`,
        `'makeSolid'`, `'closeWire'`, `'turnToParts'`, `'makeFusion'`,
        `'makeShell'`, `'makeFaces'`, `'draftify'`, `'joinFaces'`,
        `'makeSketchFace'`, `'makeWires'`.

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

    See Also
    --------
    downgrade
    """

    # definitions of actions to perform

    def makeCompound(objects):
        """Return a compound object made from the given objects."""
        newobj = make_block.make_block(objects)
        format(objects[0], [newobj])
        add_to_parent(objects[0], [newobj])
        add_list.append(newobj)
        return True

    def closeGroupWires(groups):
        """Close every open wire in the given groups."""
        result = False
        for grp in groups:
            if any([closeWire(obj) for obj in grp.Group]):
                result = True
        return result

    def makeSolid(obj):
        """Turn an object into a solid, if possible."""
        if obj.Shape.Solids:
            return False
        try:
            solid = Part.makeSolid(obj.Shape)
        except Part.OCCError:
            return False
        if not solid.isClosed():
            return False
        newobj = doc.addObject("Part::Feature", "Solid")
        newobj.Shape = solid
        format(obj, [newobj])
        add_to_parent(obj, [newobj])
        add_list.append(newobj)
        delete_list.append(obj)
        return True

    def closeWire(obj):
        """Close a wire object, if possible."""
        if obj.Shape.Faces:
            return False
        if len(obj.Shape.Wires) != 1:
            return False
        if len(obj.Shape.Edges) == 1:
            return False
        if is_straight_line(obj.Shape):
            return False
        if utils.get_type(obj) == "Wire":
            obj.Closed = True
            return True
        wire = obj.Shape.Wires[0]
        if wire.isClosed():
            return False
        verts = wire.OrderedVertexes
        p0 = verts[0].Point
        p1 = verts[-1].Point
        edges = wire.Edges
        edges.append(Part.LineSegment(p1, p0).toShape())
        wire = Part.Wire(Part.__sortEdges__(edges))
        newobj = doc.addObject("Part::Feature", "Wire")
        newobj.Shape = wire
        format(obj, [newobj])
        add_to_parent(obj, [newobj])
        add_list.append(newobj)
        delete_list.append(obj)
        return True

    def turnToParts(meshes):
        """Turn given meshes to parts."""
        result = False
        for mesh in meshes:
            shp = Arch.getShapeFromMesh(mesh.Mesh)
            if shp:
                newobj = doc.addObject("Part::Feature", shp.ShapeType)
                newobj.Shape = shp
                format(mesh, [newobj])
                add_to_parent(mesh, [newobj])
                add_list.append(newobj)
                delete_list.append(mesh)
                result = True
        return result

    def makeFusion(objects):
        """Make a Draft or Part fusion between 2 given objects."""
        newobj = doc.addObject("Part::Fuse", "Fusion")
        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 makeShell(objects):
        """Make a shell or compound with the given objects."""
        faces = []
        done_list = []
        for obj in objects:
            if obj.Shape.Faces:
                faces.extend(obj.Shape.Faces)
                done_list.append(obj)
        if not faces:
            return None
        shp = Part.makeShell(faces)
        if shp.isNull():
            return None
        newobj = doc.addObject("Part::Feature", shp.ShapeType)
        newobj.Shape = shp
        # Format before applying diffuse color:
        format(done_list[0], [newobj])
        add_to_parent(done_list[0], [newobj])
        add_list.append(newobj)
        delete_list.extend(done_list)

        if App.GuiUp and params.get_param("preserveFaceColor"):
            # Must happen after add_to_parent for correct CenterOfMass.
            colors = gui_utils.get_diffuse_color(done_list)
            if len(faces) != len(colors):
                newobj.ViewObject.DiffuseColor = [colors[0]]
            else:
                # The ordering of shp.Faces may be different. Since we cannot
                # compare via hashCode(), we have to iterate and use different
                # criteria to find the correct color.
                old_data = []
                for face, color in zip(faces, colors):
                    old_data.append([face.Area, face.CenterOfMass, color])
                new_colors = []
                for new_face in shp.Faces:
                    new_area = new_face.Area
                    new_cen = new_face.CenterOfMass
                    for old_area, old_cen, old_color in old_data:
                        if math.isclose(new_area, old_area, abs_tol=1e-7) and new_cen.isEqual(
                            old_cen, 1e-7
                        ):
                            new_colors.append(old_color)
                            break
                newobj.ViewObject.DiffuseColor = new_colors

        if params.get_param("preserveFaceNames"):
            firstName = done_list[0].Label
            nameNoTrailNumbers = re.sub(r"\d+$", "", firstName)
            newobj.Label = "{} {}".format(newobj.Label, nameNoTrailNumbers)

        return newobj

    def joinFaces(objects):
        """Make one big face from the given objects, if possible."""
        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
        if not DraftGeomUtils.is_coplanar(faces, 1e-3):
            return False
        fuse_face = faces.pop(0)
        for face in faces:
            fuse_face = fuse_face.fuse(face)
        face = DraftGeomUtils.concatenate(fuse_face)
        # check if concatenate failed
        if face.isEqual(fuse_face):
            return False
        # several coplanar and non-curved faces, they can become a Draft Wire
        if len(face.Wires) == 1 and not DraftGeomUtils.hasCurves(face):
            newobj = make_wire.make_wire(face.Wires[0], closed=True, face=True)
        # if not possible, we do a non-parametric union
        else:
            newobj = doc.addObject("Part::Feature", "Union")
            newobj.Shape = 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 makeSketchFace(obj):
        """Make a face from a sketch."""
        face = Part.makeFace(obj.Shape.Wires, "Part::FaceMakerBullseye")
        if not face:
            return False
        newobj = doc.addObject("Part::Feature", "Face")
        newobj.Shape = face
        format(obj, [newobj])
        add_to_parent(obj, [newobj])
        add_list.append(newobj)
        delete_list.append(obj)
        return True

    def makeFaces(objects):
        """Make a face from every closed wire in the given objects."""
        result = False
        for obj in objects:
            new_list = []
            for wire in obj.Shape.Wires:
                try:
                    face = Part.Face(wire)
                except Part.OCCError:
                    continue
                newobj = doc.addObject("Part::Feature", "Face")
                newobj.Shape = face
                new_list.append(newobj)
            if not new_list:
                continue
            format(obj, new_list)
            add_to_parent(obj, new_list)
            add_list.extend(new_list)
            delete_list.append(obj)
            result = True
        return result

    def makeWires(objects):
        """Join edges in the given objects into wires."""
        edges = []
        done_list = []
        for obj in objects:
            if obj.Shape.Edges:
                edges.extend(obj.Shape.Edges)
                done_list.append(obj)
        if not edges:
            return False
        try:
            sorted_edges = Part.sortEdges(edges)
            if _DEBUG:
                for cluster in sorted_edges:
                    for edge in cluster:
                        print("Curve: {}".format(edge.Curve))
                        print(
                            "first: {}, last: {}".format(
                                edge.Vertexes[0].Point, edge.Vertexes[-1].Point
                            )
                        )
            wires = [Part.Wire(cluster) for cluster in sorted_edges]
        except Part.OCCError:
            return False
        if len(objects) > 1 and len(wires) == len(objects):
            # we still have the same number of objects, we actually didn't join anything!
            return False
        new_list = []
        for wire in wires:
            newobj = doc.addObject("Part::Feature", "Wire")
            newobj.Shape = wire
            new_list.append(newobj)
        # We don't know which wire came from which obj, we format them the same:
        format(done_list[0], new_list)
        add_to_parent(done_list[0], new_list)
        add_list.extend(new_list)
        delete_list.extend(done_list)
        return True

    def _draftify(obj):
        """Wrapper for draftify."""
        new_list = draftify.draftify(obj, delete=False)
        if not new_list:
            return False
        if not isinstance(new_list, list):
            new_list = [new_list]
        format(obj, new_list)
        add_to_parent(obj, new_list)
        add_list.extend(new_list)
        delete_list.append(obj)
        return True

    # helper functions (same as in downgrade.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
    faces = []
    wires = []
    openwires = []
    facewires = []
    edges = []
    loneedges = []
    groups = []
    meshes = []
    parts = []

    for obj in objects:
        if obj.TypeId == "App::DocumentObjectGroup":
            groups.append(obj)
        elif hasattr(obj, "Shape"):
            parts.append(obj)
            faces.extend(obj.Shape.Faces)
            wires.extend(obj.Shape.Wires)
            edges.extend(obj.Shape.Edges)
            for face in obj.Shape.Faces:
                facewires.extend(face.Wires)
            wirededges = []
            for wire in obj.Shape.Wires:
                if len(wire.Edges) > 1:
                    for edge in wire.Edges:
                        wirededges.append(edge.hashCode())
                if not wire.isClosed():
                    openwires.append(wire)
            for edge in obj.Shape.Edges:
                if not edge.hashCode() in wirededges and not edge.isClosed():
                    loneedges.append(edge)
        elif obj.isDerivedFrom("Mesh::Feature"):
            meshes.append(obj)
    objects = parts

    if _DEBUG:
        print("objects: {}, edges: {}".format(objects, edges))
        print("wires: {}, openwires: {}".format(wires, openwires))
        print("faces: {}".format(faces))
        print("groups: {}".format(groups))
        print("facewires: {}, loneedges: {}".format(facewires, loneedges))

    if not (groups or objects or meshes):
        result = False

    elif force:
        if force == "closeGroupWires":
            result = closeGroupWires(groups)
        elif force == "turnToParts":
            result = turnToParts(meshes)
        else:
            # functions that work on a single object:
            single_funcs = {
                "closeWire": closeWire,
                "draftify": _draftify,
                "makeSketchFace": makeSketchFace,
                "makeSolid": makeSolid,
            }
            # functions that work on multiple objects:
            multi_funcs = {
                "joinFaces": joinFaces,
                "makeCompound": makeCompound,
                "makeFaces": makeFaces,
                "makeFusion": makeFusion,
                "makeShell": makeShell,
                "makeWires": makeWires,
            }
            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", "Upgrade: Unknown force method:") + " " + force)
                result = False

    # if we have a group: close each wire inside
    elif groups:
        result = closeGroupWires(groups)
        if result:
            _msg(translate("draft", "Found groups: closing open wires inside"))

    # if we have meshes, we try to turn them into shapes
    elif meshes:
        result = turnToParts(meshes)
        if result:
            _msg(translate("draft", "Found meshes: turning them into Part shapes"))

    else:
        # checking faces coplanarity
        # The precision needed in Part.makeFace is 1e-7. Here we use a
        # higher value to let that function throw the exception when
        # joinFaces is called if the precision is insufficient.
        if faces:
            faces_coplanarity = DraftGeomUtils.is_coplanar(faces, 1e-3)

        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 only faces
        if faces and len(facewires) == len(wires) and not openwires and not loneedges:

            # we have one shell: we try to make a solid
            # this also handles PD Bodies and PD features with solids (result will be False)
            if len(objects) == 1 and len(faces) > 3 and not faces_coplanarity:
                result = makeSolid(objects[0])
                if result:
                    _msg(translate("draft", "Found 1 solidifiable object: solidifying it"))

            # we have exactly 2 objects: we fuse them
            elif (
                len(objects) == 2
                and not faces_coplanarity
                and same_parent
                and same_parent_type != "PartDesign::Body"
            ):
                result = makeFusion(objects)
                if result:
                    _msg(translate("draft", "Found 2 objects: fusing them"))

            # we have many separate faces: we try to make a shell or compound
            elif (
                len(objects) > 1
                and len(faces) > 1
                and same_parent
                and same_parent_type != "PartDesign::Body"
            ):
                result = makeShell(objects)
                if result:
                    _msg(
                        translate(
                            "draft", "Found several objects: creating a " + result.Shape.ShapeType
                        )
                    )

            # we have faces: we try to join them if they are coplanar
            elif len(objects) == 1 and len(faces) > 1 and faces_coplanarity:
                result = joinFaces(objects)
                if result:
                    _msg(
                        translate(
                            "draft", "Found object with several coplanar faces: refining them"
                        )
                    )

            # only one object: if not parametric, we "draftify" it
            elif (
                len(objects) == 1
                and not objects[0].isDerivedFrom("Part::Part2DObjectPython")
                and not utils.get_type(objects[0]) in ["BezCurve", "BSpline", "Wire"]
            ):
                result = _draftify(objects[0])
                if result:
                    _msg(
                        translate(
                            "draft",
                            "Found 1 non-parametric object: replacing it with a Draft object",
                        )
                    )

        # in the following cases there are no faces
        elif not faces:

            # we have only closed wires
            if wires and not openwires and not loneedges:
                # we have a sketch: extract a face
                if len(objects) == 1 and objects[0].isDerivedFrom("Sketcher::SketchObject"):
                    result = makeSketchFace(objects[0])
                    if result:
                        _msg(
                            translate(
                                "draft", "Found 1 closed sketch object: creating a face from it"
                            )
                        )
                # only closed wires
                else:
                    result = makeFaces(objects)
                    if result:
                        _msg(translate("draft", "Found closed wires: creating faces"))

            # wires or edges: we try to join them
            elif len(objects) > 1 and len(edges) > 1 and same_parent:
                result = makeWires(objects)
                if result:
                    _msg(translate("draft", "Found several wires or edges: wiring them"))
                else:
                    result = makeCompound(objects)
                    if result:
                        _msg(
                            translate(
                                "draft", "Found several non-treatable objects: creating compound"
                            )
                        )

            # special case, we have only one open wire. We close it, unless it has only 1 edge!
            elif len(objects) == 1 and len(openwires) == 1:
                result = closeWire(objects[0])
                if result:
                    _msg(translate("draft", "Found 1 open wire: closing it"))

            # only one object: if not parametric, we "draftify" it
            elif (
                len(objects) == 1
                and len(edges) == 1
                and not objects[0].isDerivedFrom("Part::Part2DObjectPython")
                and not utils.get_type(objects[0]) in ["BezCurve", "BSpline", "Wire"]
            ):
                edge_type = DraftGeomUtils.geomType(objects[0].Shape.Edges[0])
                # currently only support Line and Circle
                if edge_type in ("Line", "Circle"):
                    result = _draftify(objects[0])
                    if result:
                        _msg(
                            translate(
                                "draft",
                                "Found 1 non-parametric object: replacing it with a Draft object",
                            )
                        )

            # only points, no edges
            elif not edges and len(objects) > 1:
                result = makeCompound(objects)
                if result:
                    _msg(translate("draft", "Found points: creating compound"))

        # all other cases, if more than 1 object, make a compound
        elif len(objects) > 1:
            result = makeCompound(objects)
            if result:
                _msg(translate("draft", "Found several non-treatable objects: creating compound"))

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

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

    gui_utils.select(add_list)
    return add_list, delete_list


## @}
