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

# /***************************************************************************
# *   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                                *
# *                                                                         *
# ***************************************************************************/

__title__ = "BOPTools.ShapeMerge module"
__author__ = "DeepSOIC"
__url__ = "https://www.freecad.org"
__doc__ = "Tools for merging shapes with shared elements. Useful for final processing of results of Part.Shape.generalFuse()."

import Part
from .Utils import HashableShape


def findSharedElements(shape_list, element_extractor):
    if len(shape_list) < 2:
        raise ValueError(
            "findSharedElements: at least two shapes must be provided (have {num})".format(
                num=len(shape_list)
            )
        )

    all_elements = []  # list of sets of HashableShapes
    for shape in shape_list:
        all_elements.append(set([HashableShape(sh) for sh in element_extractor(shape)]))
    shared_elements = None
    for elements in all_elements:
        if shared_elements is None:
            shared_elements = elements
        else:
            shared_elements.intersection_update(elements)
    return [el.Shape for el in shared_elements]


def isConnected(shape1, shape2, shape_dim=-1):
    if shape_dim == -1:
        shape_dim = dimensionOfShapes([shape1, shape2])
    extractor = {
        0: None,
        1: (lambda sh: sh.Vertexes),
        2: (lambda sh: sh.Edges),
        3: (lambda sh: sh.Faces),
    }[shape_dim]
    return len(findSharedElements([shape1, shape2], extractor)) > 0


def splitIntoGroupsBySharing(list_of_shapes, element_extractor, split_connections=[]):
    """splitIntoGroupsBySharing(list_of_shapes, element_type, split_connections = []): find,
    which shapes in list_of_shapes are connected into groups by sharing elements.

    element_extractor: function that takes shape as input, and returns list of shapes.

    split_connections: list of shapes to exclude when testing for connections. Use to
    split groups on purpose.

    return: list of lists of shapes. Top-level list is list of groups; bottom level lists
    enumerate shapes of a group."""

    split_connections = set([HashableShape(element) for element in split_connections])

    groups = (
        []
    )  # list of tuples (shapes,elements). Shapes is a list of plain shapes. Elements is a set of HashableShapes - all elements of shapes in the group, excluding split_connections.

    # add shapes to the list of groups, one by one. If not connected to existing groups,
    # new group is created. If connected, shape is added to groups, and the groups are joined.
    for shape in list_of_shapes:
        shape_elements = set([HashableShape(element) for element in element_extractor(shape)])
        shape_elements.difference_update(split_connections)
        # search if shape is connected to any groups
        connected_to = []
        not_in_connected_to = []
        for iGroup in range(len(groups)):
            connected = False
            for element in shape_elements:
                if element in groups[iGroup][1]:
                    connected_to.append(iGroup)
                    connected = True
                    break
            else:
                # `break` not invoked, so `connected` is false
                not_in_connected_to.append(iGroup)

        # test if we need to join groups
        if len(connected_to) > 1:
            # shape bridges a gap between some groups. Join them into one.
            # rebuilding list of groups. First, add the new "supergroup", then add the rest
            groups_new = []

            supergroup = (list(), set())
            for iGroup in connected_to:
                supergroup[0].extend(groups[iGroup][0])  # merge lists of shapes
                supergroup[1].update(groups[iGroup][1])  # merge lists of elements
            groups_new.append(supergroup)

            l_groups = len(groups)
            groups_new.extend(
                [groups[i_group] for i_group in not_in_connected_to if i_group < l_groups]
            )
            groups = groups_new
            connected_to = [0]

        # add shape to the group it is connected to (if to many, the groups should have been unified by the above code snippet)
        if len(connected_to) > 0:
            iGroup = connected_to[0]
            groups[iGroup][0].append(shape)
            groups[iGroup][1].update(shape_elements)
        else:
            newgroup = ([shape], shape_elements)
            groups.append(newgroup)

    # done. Discard unnecessary data and return result.
    return [shapes for shapes, elements in groups]


def mergeSolids(
    list_of_solids_compsolids, flag_single=False, split_connections=[], bool_compsolid=False
):
    """mergeSolids(list_of_solids, flag_single = False): merges touching solids that share
    faces. If flag_single is True, it is assumed that all solids touch, and output is a
    single solid. If flag_single is False, the output is a compound containing all
    resulting solids.

    Note. CompSolids are treated as lists of solids - i.e., merged into solids."""

    solids = []
    for sh in list_of_solids_compsolids:
        solids.extend(sh.Solids)
    if flag_single:
        cs = Part.CompSolid(solids)
        return cs if bool_compsolid else Part.makeSolid(cs)
    else:
        if len(solids) == 0:
            return Part.Compound([])
        groups = splitIntoGroupsBySharing(solids, lambda sh: sh.Faces, split_connections)
        if bool_compsolid:
            merged_solids = [Part.CompSolid(group) for group in groups]
        else:
            merged_solids = [Part.makeSolid(Part.CompSolid(group)) for group in groups]
        return Part.makeCompound(merged_solids)


def mergeShells(list_of_faces_shells, flag_single=False, split_connections=[]):
    faces = []
    for sh in list_of_faces_shells:
        faces.extend(sh.Faces)
    if flag_single:
        return Part.makeShell(faces)
    else:
        groups = splitIntoGroupsBySharing(faces, lambda sh: sh.Edges, split_connections)
        return Part.makeCompound([Part.makeShell(group) for group in groups])


def mergeWires(list_of_edges_wires, flag_single=False, split_connections=[]):
    edges = []
    for sh in list_of_edges_wires:
        edges.extend(sh.Edges)
    if flag_single:
        return Part.Wire(edges)
    else:
        groups = splitIntoGroupsBySharing(edges, lambda sh: sh.Vertexes, split_connections)
        return Part.makeCompound([Part.Wire(Part.sortEdges(group)[0]) for group in groups])


def mergeVertices(list_of_vertices, flag_single=False, split_connections=[]):
    # no comprehensive support, just following the footprint of other mergeXXX()
    return Part.makeCompound(removeDuplicates(list_of_vertices))


def mergeShapes(list_of_shapes, flag_single=False, split_connections=[], bool_compsolid=False):
    """mergeShapes(list_of_shapes, flag_single = False, split_connections = [], bool_compsolid = False):
    merges list of edges/wires into wires, faces/shells into shells, solids/compsolids
    into solids or compsolids.

    list_of_shapes: shapes to merge. Shapes must share elements in order to be merged.

    flag_single: assume all shapes in list are connected. If False, return is a compound.
    If True, return is the single piece (e.g. a shell).

    split_connections: list of shapes that are excluded when searching for connections.
    This can be used for example to split a wire in two by supplying vertices where to
    split. If flag_single is True, this argument is ignored.

    bool_compsolid: determines behavior when dealing with solids/compsolids. If True,
    result is compsolid/compound of compsolids. If False, all touching solids and
    compsolids are unified into single solids. If not merging solids/compsolids, this
    argument is ignored."""

    if len(list_of_shapes) == 0:
        return Part.Compound([])
    args = [list_of_shapes, flag_single, split_connections]
    dim = dimensionOfShapes(list_of_shapes)
    if dim == 0:
        return mergeVertices(*args)
    elif dim == 1:
        return mergeWires(*args)
    elif dim == 2:
        return mergeShells(*args)
    elif dim == 3:
        args.append(bool_compsolid)
        return mergeSolids(*args)
    else:
        assert dim >= 0 and dim <= 3


def removeDuplicates(list_of_shapes):
    hashes = set()
    new_list = []
    for sh in list_of_shapes:
        hash = HashableShape(sh)
        if hash in hashes:
            pass
        else:
            new_list.append(sh)
            hashes.add(hash)
    return new_list


def dimensionOfShapes(list_of_shapes):
    """dimensionOfShapes(list_of_shapes): returns dimension (0D, 1D, 2D, or 3D) of shapes
    in the list. If dimension of shapes varies, TypeError is raised."""

    dimensions = [["Vertex"], ["Edge", "Wire"], ["Face", "Shell"], ["Solid", "CompSolid"]]
    dim = -1
    for sh in list_of_shapes:
        sht = sh.ShapeType
        for iDim in range(len(dimensions)):
            if sht in dimensions[iDim]:
                if dim == -1:
                    dim = iDim
                if iDim != dim:
                    raise TypeError(
                        "Shapes are of different dimensions ({t1} and {t2}), and cannot be merged or compared.".format(
                            t1=list_of_shapes[0].ShapeType, t2=sht
                        )
                    )
    return dim
