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

# ***************************************************************************
# *                                                                         *
# *   Copyright (c) 2022 Yorik van Havre <yorik@uncreated.net>              *
# *                                                                         *
# *   This file is part of FreeCAD.                                         *
# *                                                                         *
# *   FreeCAD is free software: you can redistribute it and/or modify it    *
# *   under the terms of the GNU Lesser General Public License as           *
# *   published by the Free Software Foundation, either version 2.1 of the  *
# *   License, or (at your option) any later version.                       *
# *                                                                         *
# *   FreeCAD 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      *
# *   Lesser General Public License for more details.                       *
# *                                                                         *
# *   You should have received a copy of the GNU Lesser General Public      *
# *   License along with FreeCAD. If not, see                               *
# *   <https://www.gnu.org/licenses/>.                                      *
# *                                                                         *
# ***************************************************************************

"""This is the main NativeIFC module"""

import os

from PySide import QtCore

import FreeCAD
import Arch
import ArchBuildingPart
import Draft

from draftviewproviders import view_layer

translate = FreeCAD.Qt.translate

# heavyweight libraries - ifc_tools should always be lazy loaded

try:
    import ifcopenshell
    import ifcopenshell.api
    import ifcopenshell.geom
    import ifcopenshell.util.attribute
    import ifcopenshell.util.element
    import ifcopenshell.util.placement
    import ifcopenshell.util.schema
    import ifcopenshell.util.unit
    import ifcopenshell.entity_instance
except ImportError as e:
    import FreeCAD

    FreeCAD.Console.PrintError(
        translate(
            "BIM",
            "IfcOpenShell was not found on this system. IFC support is disabled",
        )
        + "\n"
    )
    raise e

from . import ifc_objects
from . import ifc_viewproviders
from . import ifc_import
from . import ifc_layers
from . import ifc_status
from . import ifc_export
from . import ifc_psets

SCALE = 1000.0  # IfcOpenShell works in meters, FreeCAD works in mm
SHORT = False  # If True, only Step ID attribute is created
ROUND = 8  # rounding value for placements
DEFAULT_SHAPEMODE = "Coin"  # Can be Shape, Coin or None
PARAMS = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/NativeIFC")


def create_document(document, filename=None, shapemode=0, strategy=0, silent=False):
    """Creates a IFC document object in the given FreeCAD document or converts that
    document into an IFC document, depending on the state of the statusbar lock button.

    filename:  If not given, a blank IFC document is created
    shapemode: 0 = full shape
               1 = coin only
               2 = no representation
    strategy:  0 = only root object
               1 = only building structure
               2 = all children
    """

    if ifc_status.get_lock_status():
        return convert_document(document, filename, shapemode, strategy, silent)
    else:
        return create_document_object(document, filename, shapemode, strategy, silent)


def create_document_object(document, filename=None, shapemode=0, strategy=0, silent=False):
    """Creates a IFC document object in the given FreeCAD document.

    filename:  If not given, a blank IFC document is created
    shapemode: 0 = full shape
               1 = coin only
               2 = no representation
    strategy:  0 = only root object
               1 = only building structure
               2 = all children
    """

    obj = add_object(document, otype="project")
    ifcfile, project, full = setup_project(obj, filename, shapemode, silent)
    # populate according to strategy
    if strategy == 0:
        pass
    elif strategy == 1:
        create_children(obj, ifcfile, recursive=True, only_structure=True)
    elif strategy == 2:
        create_children(obj, ifcfile, recursive=True, assemblies=False)
    # create default structure
    if full:
        site = aggregate(Arch.makeSite(), obj)
        building = aggregate(Arch.makeBuilding(), site)
        storey = aggregate(Arch.makeFloor(), building)
    return obj


def convert_document(document, filename=None, shapemode=0, strategy=0, silent=False):
    """Converts the given FreeCAD document to an IFC document.

    filename:  If not given, a blank IFC document is created
    shapemode: 0 = full shape
               1 = coin only
               2 = no representation
    strategy:  0 = only root object
               1 = only bbuilding structure
               2 = all children
               3 = no children
    """

    if "Proxy" not in document.PropertiesList:
        document.addProperty("App::PropertyPythonObject", "Proxy", locked=True)
    document.setPropertyStatus("Proxy", "Transient")
    document.Proxy = ifc_objects.document_object()
    ifcfile, project, full = setup_project(document, filename, shapemode, silent)
    if strategy == 0:
        create_children(document, ifcfile, recursive=False)
    elif strategy == 1:
        create_children(document, ifcfile, recursive=True, only_structure=True)
    elif strategy == 2:
        create_children(document, ifcfile, recursive=True, assemblies=False)
    elif strategy == 3:
        pass
    # create default structure
    if full:
        site = aggregate(Arch.makeSite(), document)
        building = aggregate(Arch.makeBuilding(), site)
        storey = aggregate(Arch.makeFloor(), building)
    return document


def setup_project(proj, filename, shapemode, silent):
    """Sets up a project (common operations between single doc/not single doc modes)
    Returns the ifcfile object, the project ifc entity, and full (True/False)"""

    full = False
    d = "The path to the linked IFC file"
    if "IfcFilePath" not in proj.PropertiesList:
        proj.addProperty("App::PropertyFile", "IfcFilePath", "Base", d, locked=True)
    if "Modified" not in proj.PropertiesList:
        proj.addProperty("App::PropertyBool", "Modified", "Base", locked=True)
    proj.setPropertyStatus("Modified", "Hidden")
    if filename:
        # opening existing file
        proj.IfcFilePath = filename
        ifcfile = ifcopenshell.open(filename)
    else:
        # creating a new file
        if not silent:
            full = ifc_import.get_project_type()
        ifcfile = create_ifcfile()
    project = ifcfile.by_type("IfcProject")[0]
    # TODO configure version history
    # https://blenderbim.org/docs-python/autoapi/ifcopenshell/api/owner/create_owner_history/index.html
    # In IFC4, history is optional. What should we do here?
    proj.Proxy.ifcfile = ifcfile
    add_properties(proj, ifcfile, project, shapemode=shapemode)
    if "Schema" not in proj.PropertiesList:
        proj.addProperty("App::PropertyEnumeration", "Schema", "Base", locked=True)
    # bug in FreeCAD - to avoid a crash, pre-populate the enum with one value
    proj.Schema = [ifcfile.wrapped_data.schema_name()]
    proj.Schema = ifcfile.wrapped_data.schema_name()
    proj.Schema = ifcopenshell.ifcopenshell_wrapper.schema_names()
    return ifcfile, project, full


def create_ifcfile():
    """Creates a new, empty IFC document"""

    ifcfile = api_run("project.create_file")
    project = api_run("root.create_entity", ifcfile, ifc_class="IfcProject")
    param = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Document")
    user = param.GetString("prefAuthor", "")
    user = user.split("<")[0].strip()
    org = param.GetString("prefCompany", "")
    person = None
    organisation = None
    if user:
        person = api_run("owner.add_person", ifcfile, family_name=user)
    if org:
        organisation = api_run("owner.add_organisation", ifcfile, name=org)
    if user and org:
        api_run(
            "owner.add_person_and_organisation",
            ifcfile,
            person=person,
            organisation=organisation,
        )
    application = "FreeCAD"
    version = FreeCAD.Version()
    version = ".".join([str(v) for v in version[0:3]])
    freecadorg = api_run(
        "owner.add_organisation", ifcfile, identification="FreeCAD.org", name="The FreeCAD project"
    )
    application = api_run(
        "owner.add_application",
        ifcfile,
        application_developer=freecadorg,
        application_full_name=application,
        application_identifier=application,
        version=version,
    )
    # context
    model3d = api_run("context.add_context", ifcfile, context_type="Model")
    plan = api_run("context.add_context", ifcfile, context_type="Plan")
    body = api_run(
        "context.add_context",
        ifcfile,
        context_type="Model",
        context_identifier="Body",
        target_view="MODEL_VIEW",
        parent=model3d,
    )
    api_run(
        "context.add_context",
        ifcfile,
        context_type="Model",
        context_identifier="Axis",
        target_view="GRAPH_VIEW",
        parent=model3d,
    )
    # unit
    # for now, assign a default metre + sqm +degrees unit, as per
    # https://docs.ifcopenshell.org/autoapi/ifcopenshell/api/unit/index.html
    # TODO allow to set this at creation, from the current FreeCAD units schema
    length = api_run("unit.add_si_unit", ifcfile, unit_type="LENGTHUNIT")
    area = api_run("unit.add_si_unit", ifcfile, unit_type="AREAUNIT")
    angle = api_run("unit.add_conversion_based_unit", ifcfile, name="degree")
    api_run("unit.assign_unit", ifcfile, units=[length, area, angle])
    # TODO add user history
    return ifcfile


def api_run(*args, **kwargs):
    """Runs an IfcOpenShell API call and flags the ifcfile as modified"""

    result = ifcopenshell.api.run(*args, **kwargs)
    # *args are typically command, ifcfile
    if len(args) > 1:
        ifcfile = args[1]
        for d in FreeCAD.listDocuments().values():
            for o in d.Objects:
                if hasattr(o, "Proxy") and hasattr(o.Proxy, "ifcfile"):
                    if o.Proxy.ifcfile == ifcfile:
                        o.Modified = True
    return result


def create_object(ifcentity, document, ifcfile, shapemode=0, objecttype=None):
    """Creates a FreeCAD object from an IFC entity"""

    exobj = get_object(ifcentity, document)
    if exobj:
        return exobj
    s = "IFC: Created #{}: {}, '{}'\n".format(
        ifcentity.id(), ifcentity.is_a(), getattr(ifcentity, "Name", "")
    )
    objecttype = ifc_export.get_object_type(ifcentity, objecttype)
    FreeCAD.Console.PrintLog(s)
    obj = add_object(document, otype=objecttype)
    add_properties(obj, ifcfile, ifcentity, shapemode=shapemode)
    ifc_layers.add_layers(obj, ifcentity, ifcfile)
    if FreeCAD.GuiUp:
        if (
            ifcentity.is_a("IfcSpace")
            or ifcentity.is_a("IfcOpeningElement")
            or ifcentity.is_a("IfcAnnotation")
        ):
            try:
                obj.ViewObject.DisplayMode = "Wireframe"
            except:
                pass
    elements = [ifcentity]
    return obj


def create_children(
    obj,
    ifcfile=None,
    recursive=False,
    only_structure=False,
    assemblies=True,
    expand=False,
):
    """Creates a hierarchy of objects under an object"""

    def get_parent_objects(parent):
        proj = get_project(parent)
        if hasattr(proj, "OutListRecursive"):
            return proj.OutListRecursive
        elif hasattr(proj, "Objects"):
            return proj.Objects

    def create_child(parent, element):
        subresult = []
        # do not create if a child with same stepid already exists
        if element.id() not in [getattr(c, "StepId", 0) for c in get_parent_objects(parent)]:
            doc = getattr(parent, "Document", parent)
            mode = getattr(parent, "ShapeMode", "Coin")
            child = create_object(element, doc, ifcfile, mode)
            subresult.append(child)
            if isinstance(parent, FreeCAD.DocumentObject):
                parent.Proxy.addObject(parent, child)
            if element.is_a("IfcSite"):
                # force-create contained buildings too if we just created a site
                buildings = [o for o in get_children(child, ifcfile) if o.is_a("IfcBuilding")]
                for building in buildings:
                    subresult.extend(create_child(child, building))
            elif element.is_a("IfcOpeningElement"):
                # force-create contained windows too if we just created an opening
                windows = [
                    o for o in get_children(child, ifcfile) if o.is_a() in ("IfcWindow", "IfcDoor")
                ]
                for window in windows:
                    subresult.extend(create_child(child, window))

            if recursive:
                subresult.extend(
                    create_children(child, ifcfile, recursive, only_structure, assemblies)
                )
        return subresult

    if not ifcfile:
        ifcfile = get_ifcfile(obj)
    result = []
    children = get_children(obj, ifcfile, only_structure, assemblies, expand)
    for child in children:
        result.extend(create_child(obj, child))
    assign_groups(children)
    # TEST: mark new objects to recompute
    QtCore.QTimer.singleShot(0, lambda: recompute([get_object(c) for c in children]))
    return result


def assign_groups(children, ifcfile=None):
    """Fill the groups in this list. Returns a list of processed FreeCAD objects"""

    result = []
    for child in children:
        if child.is_a("IfcGroup"):
            mode = "IsGroupedBy"
        elif child.is_a("IfcElementAssembly"):
            mode = "IsDecomposedBy"
        else:
            mode = None
        if mode:
            grobj = get_object(child, None, ifcfile)
            for rel in getattr(child, mode):
                for elem in rel.RelatedObjects:
                    elobj = get_object(elem, None, ifcfile)
                    if elobj:
                        if len(elobj.InList) == 1:
                            p = elobj.InList[0]
                            if elobj in p.Group:
                                g = p.Group
                                g.remove(elobj)
                                p.Group = g
                        g = grobj.Group
                        g.append(elobj)
                        grobj.Group = g
                        result.append(elobj)
    return result


def get_children(
    obj, ifcfile=None, only_structure=False, assemblies=True, expand=False, ifctype=None
):
    """Returns the direct descendants of an object"""

    if not ifcfile:
        ifcfile = get_ifcfile(obj)
    ifcentity = ifcfile[obj.StepId]
    children = []
    if assemblies or not ifcentity.is_a("IfcElement"):
        for rel in getattr(ifcentity, "IsDecomposedBy", []):
            children.extend(rel.RelatedObjects)
    if not only_structure:
        for rel in getattr(ifcentity, "ContainsElements", []):
            children.extend(rel.RelatedElements)
        for rel in getattr(ifcentity, "HasOpenings", []):
            children.extend([rel.RelatedOpeningElement])
        for rel in getattr(ifcentity, "HasFillings", []):
            children.extend([rel.RelatedBuildingElement])
    result = filter_elements(children, ifcfile, expand=expand, spaces=True, assemblies=assemblies)
    if ifctype:
        result = [r for r in result if r.is_a(ifctype)]
    return result


def get_freecad_children(obj):
    """Returns the children of this object that exist in the document"""

    objs = []
    children = get_children(obj)
    for child in children:
        childobj = get_object(child)
        if childobj:
            objs.extend(get_freecad_children(childobj))
    return objs


def get_object(element, document=None, ifcfile=None):
    """Returns the object that references this element, if any"""

    if document:
        ldocs = {"document": document}
    else:
        ldocs = FreeCAD.listDocuments()
    for n, d in ldocs.items():
        for obj in d.Objects:
            if hasattr(obj, "StepId"):
                if obj.StepId == element.id():
                    if get_ifc_element(obj, ifcfile) == element:
                        return obj
    return None


def get_ifcfile(obj):
    """Returns the ifcfile that handles this object"""

    project = get_project(obj)
    if project:
        if getattr(project, "Proxy", None):
            if hasattr(project.Proxy, "ifcfile"):
                return project.Proxy.ifcfile
        if getattr(project, "IfcFilePath", None):
            ifcfile = ifcopenshell.open(project.IfcFilePath)
            if hasattr(project, "Proxy"):
                if project.Proxy is None:
                    if not isinstance(project, FreeCAD.DocumentObject):
                        project.Proxy = ifc_objects.document_object()
            if getattr(project, "Proxy", None):
                project.Proxy.ifcfile = ifcfile
            return ifcfile
        else:
            FreeCAD.Console.PrintError(
                "Error: No IFC file attached to this project: " + project.Label
            )
    return None


def get_project(obj):
    """Returns the ifc document this object belongs to.
    obj can be either a document object, an ifcfile or ifc element instance"""

    proj_types = ("IfcProject", "IfcProjectLibrary")
    if isinstance(obj, ifcopenshell.file):
        for d in FreeCAD.listDocuments().values():
            for o in d.Objects:
                if hasattr(o, "Proxy") and hasattr(o.Proxy, "ifcfile"):
                    if o.Proxy.ifcfile == obj:
                        return o
        return None
    if isinstance(obj, ifcopenshell.entity_instance):
        obj = get_object(obj)
    if hasattr(obj, "IfcFilePath"):
        return obj
    if hasattr(getattr(obj, "Document", None), "IfcFilePath"):
        return obj.Document
    if getattr(obj, "Class", None) in proj_types:
        return obj
    if hasattr(obj, "InListRecursive"):
        for parent in obj.InListRecursive:
            if getattr(parent, "Class", None) in proj_types:
                return parent
    return None


def can_expand(obj, ifcfile=None):
    """Returns True if this object can have any more child extracted"""

    if not ifcfile:
        ifcfile = get_ifcfile(obj)
    children = get_children(obj, ifcfile, expand=True)
    group = [o.StepId for o in obj.Group if hasattr(o, "StepId")]
    for child in children:
        if child.id() not in group:
            return True
    return False


def add_object(document, otype=None, oname="IfcObject"):
    """adds a new object to a FreeCAD document.
    otype can be:
    'project',
    'group',
    'material',
    'layer',
    'text',
    'dimension',
    'sectionplane',
    'axis',
    'schedule'
    'buildingpart'
    or anything else for a standard IFC object"""

    if not document:
        return None
    if otype == "schedule":
        obj = Arch.makeSchedule()
    elif otype == "sectionplane":
        obj = Arch.makeSectionPlane()
        obj.Proxy = ifc_objects.ifc_object(otype)
    elif otype == "axis":
        obj = Arch.makeAxis()
        obj.Proxy = ifc_objects.ifc_object(otype)
        obj.removeProperty("Angles")
        obj.removeProperty("Distances")
        obj.removeProperty("Labels")
        obj.removeProperty("Limit")
        if obj.ViewObject:
            obj.ViewObject.DisplayMode = "Flat Lines"
    elif otype == "dimension":
        obj = Draft.make_dimension(FreeCAD.Vector(), FreeCAD.Vector(1, 0, 0))
        obj.Proxy = ifc_objects.ifc_object(otype)
        obj.removeProperty("Diameter")
        obj.removeProperty("Distance")
        obj.setPropertyStatus("LinkedGeometry", "Hidden")
        obj.setGroupOfProperty("Start", "Dimension")
        obj.setGroupOfProperty("End", "Dimension")
        obj.setGroupOfProperty("Direction", "Dimension")
    elif otype == "text":
        obj = Draft.make_text("")
        obj.Proxy = ifc_objects.ifc_object(otype)
    elif otype == "layer":
        proxy = ifc_objects.ifc_object(otype)
        obj = document.addObject("App::FeaturePython", oname, proxy, None, False)
        if obj.ViewObject:
            view_layer.ViewProviderLayer(obj.ViewObject)
            obj.ViewObject.addProperty("App::PropertyBool", "HideChildren", "Layer", locked=True)
            obj.ViewObject.HideChildren = True
    elif otype == "group":
        vproxy = ifc_viewproviders.ifc_vp_group()
        obj = document.addObject("App::DocumentObjectGroupPython", oname, None, vproxy, False)
    elif otype == "material":
        proxy = ifc_objects.ifc_object(otype)
        vproxy = ifc_viewproviders.ifc_vp_material()
        obj = document.addObject("App::MaterialObjectPython", oname, proxy, vproxy, False)
    elif otype == "project":
        proxy = ifc_objects.ifc_object(otype)
        vproxy = ifc_viewproviders.ifc_vp_document()
        obj = document.addObject("Part::FeaturePython", oname, proxy, vproxy, False)
    elif otype == "buildingpart":
        obj = Arch.makeBuildingPart()
        if obj.ViewObject:
            obj.ViewObject.ShowLevel = False
            obj.ViewObject.ShowLabel = False
            obj.ViewObject.Proxy = ifc_viewproviders.ifc_vp_buildingpart(obj.ViewObject)
            obj.ViewObject.Proxy.attach(obj.ViewObject)
        for p in obj.PropertiesList:
            if obj.getGroupOfProperty(p) in ["BuildingPart", "IFC Attributes", "Children"]:
                obj.removeProperty(p)
        obj.Proxy = ifc_objects.ifc_object(otype)
    else:  # default case, standard IFC object
        proxy = ifc_objects.ifc_object(otype)
        vproxy = ifc_viewproviders.ifc_vp_object()
        obj = document.addObject("Part::FeaturePython", oname, proxy, vproxy, False)
    return obj


def add_properties(obj, ifcfile=None, ifcentity=None, links=False, shapemode=0, short=SHORT):
    """Adds the properties of the given IFC object to a FreeCAD object"""

    if not ifcfile:
        ifcfile = get_ifcfile(obj)
    if not ifcentity:
        ifcentity = get_ifc_element(obj)
    if getattr(ifcentity, "Name", None):
        obj.Label = ifcentity.Name
    elif getattr(obj, "IfcFilePath", ""):
        obj.Label = os.path.splitext(os.path.basename(obj.IfcFilePath))[0]
    else:
        obj.Label = "_" + ifcentity.is_a()
    if isinstance(obj, FreeCAD.DocumentObject) and "Group" not in obj.PropertiesList:
        obj.addProperty("App::PropertyLinkList", "Group", "Base", locked=True)
    if "ShapeMode" not in obj.PropertiesList:
        obj.addProperty("App::PropertyEnumeration", "ShapeMode", "Base", locked=True)
        shapemodes = [
            "Shape",
            "Coin",
            "None",
        ]  # possible shape modes for all IFC objects
        if isinstance(shapemode, int):
            shapemode = shapemodes[shapemode]
        obj.ShapeMode = shapemodes
        obj.ShapeMode = shapemode
        if not obj.isDerivedFrom("Part::Feature"):
            obj.setPropertyStatus("ShapeMode", "Hidden")
    if ifcentity.is_a("IfcProduct"):
        obj.addProperty("App::PropertyLink", "Type", "IFC", locked=True)
    attr_defs = ifcentity.wrapped_data.declaration().as_entity().all_attributes()
    try:
        info_ifcentity = ifcentity.get_info()
    except:
        # slower but no errors
        info_ifcentity = get_elem_attribs(ifcentity)
    for attr, value in info_ifcentity.items():
        if attr == "type":
            attr = "Class"
        elif attr == "id":
            attr = "StepId"
        elif attr == "Name":
            continue
        if short and attr not in ("Class", "StepId"):
            continue
        attr_def = next((a for a in attr_defs if a.name() == attr), None)
        data_type = ifcopenshell.util.attribute.get_primitive_type(attr_def) if attr_def else None
        if attr == "Class":
            # main enum property, not saved to file
            if attr not in obj.PropertiesList:
                obj.addProperty("App::PropertyEnumeration", attr, "IFC", locked=True)
                obj.setPropertyStatus(attr, "Transient")
            # to avoid bug/crash: we populate first the property with only the
            # class, then we add the sibling classes
            setattr(obj, attr, [value])
            setattr(obj, attr, value)
            setattr(obj, attr, get_ifc_classes(obj, value))
            # companion hidden propertym that gets saved to file
            if "IfcClass" not in obj.PropertiesList:
                obj.addProperty("App::PropertyString", "IfcClass", "IFC", locked=True)
                obj.setPropertyStatus("IfcClass", "Hidden")
            setattr(obj, "IfcClass", value)
        elif attr_def and "IfcLengthMeasure" in str(attr_def.type_of_attribute()):
            obj.addProperty("App::PropertyDistance", attr, "IFC")
            if value:
                setattr(obj, attr, value * (1 / get_scale(ifcfile)))
        elif isinstance(value, int):
            if attr not in obj.PropertiesList:
                obj.addProperty("App::PropertyInteger", attr, "IFC", locked=True)
                if attr == "StepId":
                    obj.setPropertyStatus(attr, "ReadOnly")
            setattr(obj, attr, value)
        elif isinstance(value, float):
            if attr not in obj.PropertiesList:
                obj.addProperty("App::PropertyFloat", attr, "IFC", locked=True)
            setattr(obj, attr, value)
        elif data_type == "boolean":
            if attr not in obj.PropertiesList:
                obj.addProperty("App::PropertyBool", attr, "IFC", locked=True)
            if not value or value in ["UNKNOWN", "FALSE"]:
                value = False
            elif not isinstance(value, bool):
                print("DEBUG: attempting to set boolean value:", attr, value)
                value = bool(value)
            setattr(obj, attr, value)  # will trigger error. TODO: Fix this
        elif isinstance(value, ifcopenshell.entity_instance):
            if links:
                if attr not in obj.PropertiesList:
                    obj.addProperty("App::PropertyLink", attr, "IFC", locked=True)
        elif isinstance(value, (list, tuple)) and value:
            if isinstance(value[0], ifcopenshell.entity_instance):
                if links:
                    if attr not in obj.PropertiesList:
                        obj.addProperty("App::PropertyLinkList", attr, "IFC", locked=True)
        elif data_type == "enum":
            if attr not in obj.PropertiesList:
                obj.addProperty("App::PropertyEnumeration", attr, "IFC", locked=True)
            items = ifcopenshell.util.attribute.get_enum_items(attr_def)
            if value not in items:
                for v in ("UNDEFINED", "NOTDEFINED", "USERDEFINED"):
                    if v in items:
                        value = v
                        break
            if value in items:
                # to prevent bug/crash, we first need to populate the
                # enum with the value about to be used, then
                # add the alternatives
                setattr(obj, attr, [value])
                setattr(obj, attr, value)
                setattr(obj, attr, items)
        elif attr in ["RefLongitude", "RefLatitude"]:
            obj.addProperty("App::PropertyFloat", attr, "IFC", locked=True)
            if value is not None:
                # convert from list of 4 ints
                value = value[0] + value[1] / 60.0 + value[2] / 3600.0 + value[3] / 3600.0e6
                setattr(obj, attr, value)
        else:
            if attr not in obj.PropertiesList:
                obj.addProperty("App::PropertyString", attr, "IFC", locked=True)
            if value is not None:
                setattr(obj, attr, str(value))

            # We shortly go through the list of IFCRELASSOCIATESCLASSIFICATION members
            # in the file to see if the newly added object should have a Classification added
            # since we can run `add_properties`, when changing from IFC Object to IFC Type, or BIM Object (Standard Code)
            # to BIM Type, and during the process of creation the only place where we save Classification is
            # the file itself, so below code retrieves it and assigns it back to the newly created obj.
            if not hasattr(obj, "Classification"):
                assoc_classifications = ifcfile.by_type("IfcRelAssociatesClassification")
                for assoc in assoc_classifications:
                    related_objects = assoc.RelatedObjects
                    if isinstance(related_objects, ifcopenshell.entity_instance):
                        related_objects = [related_objects]
                    if ifcentity in related_objects:
                        cref = assoc.RelatingClassification
                        if cref and cref.is_a("IfcClassificationReference"):
                            classification_name = ""

                            # Try to get the source classification name
                            if hasattr(cref, "ReferencedSource") and cref.ReferencedSource:
                                if (
                                    hasattr(cref.ReferencedSource, "Name")
                                    and cref.ReferencedSource.Name
                                ):
                                    classification_name += cref.ReferencedSource.Name + " "

                            # Add the Identification if present
                            if cref.Identification:
                                classification_name += cref.Identification

                            classification_name = classification_name.strip()
                            if classification_name:
                                obj.addProperty(
                                    "App::PropertyString", "Classification", "IFC", locked=True
                                )
                                setattr(obj, "Classification", classification_name)
                                break  # Found the relevant one, stop
    # annotation properties
    if ifcentity.is_a("IfcGridAxis"):
        axisdata = ifc_export.get_axis(ifcentity)
        if axisdata:
            if "Placement" not in obj.PropertiesList:
                obj.addProperty("App::PropertyPlacement", "Placement", "Base", locked=True)
            if "CustomText" in obj.PropertiesList:
                obj.setPropertyStatus("CustomText", "Hidden")
                obj.setExpression("CustomText", "AxisTag")
            if "Length" not in obj.PropertiesList:
                obj.addProperty("App::PropertyLength", "Length", "Axis", locked=True)
            if "Text" not in obj.PropertiesList:
                obj.addProperty("App::PropertyStringList", "Text", "Base", locked=True)
            obj.Placement = axisdata[0]
            obj.Length = axisdata[1]
            # axisdata[2] is the axis tag, it is already applied by other code
    elif ifcentity.is_a("IfcAnnotation"):
        sectionplane = ifc_export.get_sectionplane(ifcentity)
        if sectionplane:
            if "Placement" not in obj.PropertiesList:
                obj.addProperty("App::PropertyPlacement", "Placement", "Base", locked=True)
            if "Depth" not in obj.PropertiesList:
                obj.addProperty("App::PropertyLength", "Depth", "SectionPlane", locked=True)
            obj.Placement = sectionplane[0]
            if len(sectionplane) > 3:
                obj.Depth = sectionplane[3]
            vobj = obj.ViewObject
            if vobj:
                if "DisplayLength" not in vobj.PropertiesList:
                    vobj.addProperty(
                        "App::PropertyLength", "DisplayLength", "SectionPlane", locked=True
                    )
                if "DisplayHeight" not in vobj.PropertiesList:
                    vobj.addProperty(
                        "App::PropertyLength", "DisplayHeight", "SectionPlane", locked=True
                    )
                if len(sectionplane) > 1:
                    vobj.DisplayLength = sectionplane[1]
                if len(sectionplane) > 2:
                    vobj.DisplayHeight = sectionplane[2]
        else:
            dim = ifc_export.get_dimension(ifcentity)
            if dim and len(dim) >= 3:
                if "Start" not in obj.PropertiesList:
                    obj.addProperty("App::PropertyVectorDistance", "Start", "Base", locked=True)
                if "End" not in obj.PropertiesList:
                    obj.addProperty("App::PropertyVectorDistance", "End", "Base", locked=True)
                if "Dimline" not in obj.PropertiesList:
                    obj.addProperty("App::PropertyVectorDistance", "Dimline", "Base", locked=True)
                obj.Start = dim[1]
                obj.End = dim[2]
                if len(dim) > 3:
                    obj.Dimline = dim[3]
                else:
                    mid = obj.End.sub(obj.Start)
                    mid.multiply(0.5)
                    obj.Dimline = obj.Start.add(mid)
            else:
                text = ifc_export.get_text(ifcentity)
                if text:
                    if "Placement" not in obj.PropertiesList:
                        obj.addProperty("App::PropertyPlacement", "Placement", "Base", locked=True)
                    if "Text" not in obj.PropertiesList:
                        obj.addProperty("App::PropertyStringList", "Text", "Base", locked=True)
                    obj.Text = [text.Literal]
                    obj.Placement = ifc_export.get_placement(ifcentity.ObjectPlacement, ifcfile)
    elif ifcentity.is_a("IfcControl"):
        ifc_psets.show_psets(obj)

    # link Label2 and Description
    if "Description" in obj.PropertiesList and hasattr(obj, "setExpression"):
        obj.setExpression("Label2", "Description")


def remove_unused_properties(obj):
    """Remove IFC properties if they are not part of the current IFC class"""

    elt = get_ifc_element(obj)
    props = list(elt.get_info().keys())
    props[props.index("id")] = "StepId"
    props[props.index("type")] = "Class"
    for prop in obj.PropertiesList:
        if obj.getGroupOfProperty(prop) == "IFC":
            if prop not in props:
                obj.removeProperty(prop)


def get_ifc_classes(obj, baseclass):
    """Returns a list of sibling classes from a given FreeCAD object"""

    # this function can become pure IFC

    if baseclass in ("IfcProject", "IfcProjectLibrary"):
        return ("IfcProject", "IfcProjectLibrary")
    ifcfile = get_ifcfile(obj)
    if not ifcfile:
        return [baseclass]
    classes = []
    schema = ifcfile.wrapped_data.schema_name()
    schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(schema)
    declaration = schema.declaration_by_name(baseclass)
    if "StandardCase" in baseclass:
        declaration = declaration.supertype()
    if declaration.supertype():
        # include sibling classes
        classes = [sub.name() for sub in declaration.supertype().subtypes()]
        # include superclass too so one can "navigate up"
        classes.append(declaration.supertype().name())
    # also include subtypes of the current class (ex, StandardCases)
    classes.extend([sub.name() for sub in declaration.subtypes()])
    if baseclass not in classes:
        classes.append(baseclass)
    return classes


def get_ifc_element(obj, ifcfile=None):
    """Returns the corresponding IFC element of an object"""

    if not ifcfile:
        ifcfile = get_ifcfile(obj)
    if ifcfile and hasattr(obj, "StepId"):
        try:
            return ifcfile.by_id(obj.StepId)
        except RuntimeError:
            # entity not found
            pass
    return None


def has_representation(element):
    """Tells if an elements has an own representation"""

    # This function can become pure IFC

    if hasattr(element, "Representation") and element.Representation:
        return True
    return False


def filter_elements(elements, ifcfile, expand=True, spaces=False, assemblies=True):
    """Filter elements list of unwanted classes"""

    # This function can become pure IFC

    # gather decomposition if needed
    if not isinstance(elements, (list, tuple)):
        elements = [elements]
    openings = False
    if assemblies and any([e.is_a("IfcOpeningElement") for e in elements]):
        openings = True
    if expand and (len(elements) == 1):
        elem = elements[0]
        if elem.is_a("IfcSpace"):
            spaces = True
        if not has_representation(elem):
            if elem.is_a("IfcProject"):
                elements = ifcfile.by_type("IfcElement")
                elements.extend(ifcfile.by_type("IfcSite"))
            else:
                decomp = ifcopenshell.util.element.get_decomposition(elem)
                if decomp:
                    # avoid replacing elements if decomp is empty
                    elements = decomp
        else:
            if elem.Representation.Representations:
                rep = elem.Representation.Representations[0]
                if rep.Items and rep.Items[0].is_a() == "IfcPolyline" and elem.IsDecomposedBy:
                    # only use the decomposition and not the polyline
                    # happens for multilayered walls exported by VectorWorks
                    # the Polyline is the wall axis
                    # see https://github.com/yorikvanhavre/FreeCAD-NativeIFC/issues/28
                    elements = ifcopenshell.util.element.get_decomposition(elem)
    if not openings:
        # Never load feature elements by default, they can be lazy loaded
        elements = [e for e in elements if not e.is_a("IfcFeatureElement")]
    # do load spaces when required, otherwise skip computing their shapes
    if not spaces:
        elements = [e for e in elements if not e.is_a("IfcSpace")]
    # skip projects
    elements = [e for e in elements if not e.is_a("IfcProject")]
    # skip furniture for now, they can be lazy loaded probably
    elements = [e for e in elements if not e.is_a("IfcFurnishingElement")]
    return elements


def set_attribute(ifcfile, element, attribute, value):
    """Sets the value of an attribute of an IFC element"""

    # This function can become pure IFC

    def differs(val1, val2):
        if val1 == val2:
            return False
        if not val1 and not val2:
            return False
        if isinstance(val1, (tuple, list)):
            if tuple(val1) == tuple(val2):
                return False
        if val1 is None and "NOTDEFINED" in str(val2).upper():
            return False
        if val1 is None and "UNDEFINED" in str(val2).upper():
            return False
        if val2 is None and "NOTDEFINED" in str(val1).upper():
            return False
        if val2 is None and "UNDEFINED" in str(val1).upper():
            return False
        return True

    if not ifcfile or not element:
        return False
    if isinstance(value, FreeCAD.Units.Quantity):
        f = get_scale(ifcfile)
        value = value.Value * f
    if attribute == "Class":
        if value != element.is_a():
            if value and value.startswith("Ifc"):
                cmd = "root.reassign_class"
                FreeCAD.Console.PrintLog(
                    "Changing IFC class value: " + element.is_a() + " to " + str(value) + "\n"
                )
                product = api_run(cmd, ifcfile, product=element, ifc_class=value)
                # TODO fix attributes
                return product
    if attribute in ["RefLongitude", "RefLatitude"]:
        c = [int(value)]
        c.append(int((value - c[0]) * 60))
        c.append(int(((value - c[0]) * 60 - c[1]) * 60))
        c.append(int((((value - c[0]) * 60 - c[1]) * 60 - c[2]) * 1.0e6))
        value = c
    cmd = "attribute.edit_attributes"
    attribs = {attribute: value}
    if hasattr(element, attribute):
        if attribute == "Name" and getattr(element, attribute) is None and value.startswith("_"):
            # do not consider default FreeCAD names given to unnamed alements
            return False
        if differs(getattr(element, attribute, None), value):
            FreeCAD.Console.PrintLog(
                "Changing IFC attribute value of "
                + str(attribute)
                + ": "
                + str(value)
                + " (original value:"
                + str(getattr(element, attribute))
                + ")"
                + "\n"
            )
            api_run(cmd, ifcfile, product=element, attributes=attribs)
            return True
    return False


def set_colors(obj, colors):
    """Sets the given colors to an object"""

    if FreeCAD.GuiUp and colors:
        try:
            vobj = obj.ViewObject
        except ReferenceError:
            # Object was probably deleted
            return
        # ifcopenshell issues (-1,-1,-1) colors if not set
        if isinstance(colors[0], (tuple, list)):
            colors = [tuple([abs(d) for d in c]) for c in colors]
        else:
            colors = [abs(c) for c in colors]
        if hasattr(vobj, "ShapeColor"):
            # 1.0 materials
            if not isinstance(colors[0], (tuple, list)):
                colors = [colors]
            # set the first color to opaque otherwise it spoils object transparency
            if len(colors) > 1:
                # TEMP HACK: if multiple colors, set everything to opaque because it looks wrong
                colors = [color[:3] + (1.0,) for color in colors]
            sapp = []
            for color in colors:
                sapp_mat = FreeCAD.Material()
                if len(color) < 4:
                    sapp_mat.DiffuseColor = color + (1.0,)
                else:
                    sapp_mat.DiffuseColor = color[:3] + (1.0 - color[3],)
                sapp_mat.Transparency = 1.0 - color[3] if len(color) > 3 else 0.0
                sapp.append(sapp_mat)
            vobj.ShapeAppearance = sapp


def get_body_context_ids(ifcfile):
    # This function can become pure IFC

    # Facetation is to accommodate broken Revit files
    # See https://forums.buildingsmart.org/t/suggestions-on-how-to-improve-clarity\
    # -of-representation-context-usage-in-documentation/3663/6?u=moult
    body_contexts = [
        c.id()
        for c in ifcfile.by_type("IfcGeometricRepresentationSubContext")
        if c.ContextIdentifier in ["Body", "Facetation"]
    ]
    # Ideally, all representations should be in a subcontext, but some BIM apps don't do this
    # correctly, so we add main contexts too
    body_contexts.extend(
        [
            c.id()
            for c in ifcfile.by_type("IfcGeometricRepresentationContext", include_subtypes=False)
            if c.ContextType == "Model"
        ]
    )
    return body_contexts


def get_plan_contexts_ids(ifcfile):
    # This function can become pure IFC

    # Annotation is to accommodate broken Revit files
    # See https://github.com/Autodesk/revit-ifc/issues/187
    return [
        c.id()
        for c in ifcfile.by_type("IfcGeometricRepresentationContext")
        if c.ContextType in ["Plan", "Annotation"]
    ]


def get_freecad_matrix(ios_matrix):
    """Converts an IfcOpenShell matrix tuple into a FreeCAD matrix"""

    # https://github.com/IfcOpenShell/IfcOpenShell/issues/1440
    # https://pythoncvc.net/?cat=203
    # https://github.com/IfcOpenShell/IfcOpenShell/issues/4832#issuecomment-2158583873
    m_l = list()
    for i in range(3):
        if len(ios_matrix) == 16:
            # IfcOpenShell 0.8
            line = list(ios_matrix[i::4])
        else:
            # IfcOpenShell 0.7
            line = list(ios_matrix[i::3])
        line[-1] *= SCALE
        m_l.extend(line)
    return FreeCAD.Matrix(*m_l)


def get_ios_matrix(m):
    """Converts a FreeCAD placement or matrix into an IfcOpenShell matrix tuple"""

    if isinstance(m, FreeCAD.Placement):
        m = m.Matrix
    mat = [
        [m.A11, m.A12, m.A13, m.A14],
        [m.A21, m.A22, m.A23, m.A24],
        [m.A31, m.A32, m.A33, m.A34],
        [m.A41, m.A42, m.A42, m.A44],
    ]
    # apply rounding because OCCT often changes 1.0 to 0.99999999999 or something
    rmat = []
    for row in mat:
        rmat.append([round(e, ROUND) for e in row])
    return rmat


def get_scale(ifcfile):
    """Returns the scale factor to convert any file length to mm"""

    scale = ifcopenshell.util.unit.calculate_unit_scale(ifcfile)
    # the above lines yields meter -> file unit scale factor. We need mm
    return 0.001 / scale


def set_placement(obj):
    """Updates the internal IFC placement according to the object placement"""

    # This function can become pure IFC

    ifcfile = get_ifcfile(obj)
    if not ifcfile:
        print("DEBUG: No ifc file for object", obj.Label, "Aborting")
    if obj.Class in ["IfcProject", "IfcProjectLibrary"]:
        return
    element = get_ifc_element(obj)
    if not hasattr(element, "ObjectPlacement"):
        # special case: this is a grid axis, it has no placement
        if element.is_a("IfcGridAxis"):
            return set_axis_points(obj, element, ifcfile)
        # other cases of objects without ObjectPlacement?
        print("DEBUG: object without ObjectPlacement", element)
        return False
    placement = FreeCAD.Placement(obj.Placement)
    placement.Base = FreeCAD.Vector(placement.Base).multiply(get_scale(ifcfile))
    new_matrix = get_ios_matrix(placement)
    old_matrix = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
    # conversion from numpy array
    old_matrix = old_matrix.tolist()
    old_matrix = [[round(c, ROUND) for c in r] for r in old_matrix]
    if new_matrix != old_matrix:
        FreeCAD.Console.PrintLog(
            "IFC: placement changed for "
            + obj.Label
            + " old: "
            + str(old_matrix)
            + " new: "
            + str(new_matrix)
            + "\n"
        )
        api = "geometry.edit_object_placement"
        api_run(api, ifcfile, product=element, matrix=new_matrix, is_si=False)
        return True
    return False


def set_axis_points(obj, element, ifcfile):
    """Sets the points of an axis from placement and length"""

    if element.AxisCurve.is_a("IfcPolyline"):
        p1 = obj.Placement.Base
        p2 = obj.Placement.multVec(FreeCAD.Vector(0, obj.Length.Value, 0))
        api_run(
            "attribute.edit_attributes",
            ifcfile,
            product=element.AxisCurve.Points[0],
            attributes={"Coordinates": tuple(p1)},
        )
        api_run(
            "attribute.edit_attributes",
            ifcfile,
            product=element.AxisCurve.Points[-1],
            attributes={"Coordinates": tuple(p2)},
        )
        return True
    print("DEBUG: unhandled axis type:", element.AxisCurve.is_a())
    return False


def save_ifc(obj, filepath=None):
    """Saves the linked IFC file of a project, but does not mark it as saved"""

    if not filepath:
        if getattr(obj, "IfcFilePath", None):
            filepath = obj.IfcFilePath
    if filepath:
        ifcfile = get_ifcfile(obj)
        if not ifcfile:
            ifcfile = create_ifcfile()
        ifcfile.write(filepath)
        FreeCAD.Console.PrintMessage("Saved " + filepath + "\n")


def save(obj, filepath=None):
    """Saves the linked IFC file of a project and set its saved status"""

    save_ifc(obj, filepath)
    obj.Modified = False


def aggregate(obj, parent, mode=None):
    """Takes any FreeCAD object and aggregates it to an existing IFC object.
    Mode can be 'opening' to force-create a subtraction"""

    proj = get_project(parent)
    if not proj:
        FreeCAD.Console.PrintError("The parent object is not part of an IFC project\n")
        return
    ifcfile = get_ifcfile(proj)
    if not ifcfile:
        return
    product = None
    objecttype = None
    new = False
    stepid = getattr(obj, "StepId", None)
    if stepid:
        # obj might be dragging at this point and has no project anymore
        try:
            elem = ifcfile[stepid]
            if obj.GlobalId == elem.GlobalId:
                product = elem
        except:
            pass
    if product:
        # this object already has an associated IFC product
        # print("DEBUG:", obj.Label, "is already part of the IFC document")
        newobj = obj
    else:
        ifcclass = None
        if mode == "opening":
            ifcclass = "IfcOpeningElement"
        if ifc_export.is_annotation(obj):
            product = ifc_export.create_annotation(obj, ifcfile)
            if Draft.get_type(obj) in ["DraftText", "Text"]:
                objecttype = "text"
        elif "CreateSpreadsheet" in obj.PropertiesList:
            obj.Proxy.create_ifc(obj, ifcfile)
            newobj = obj
        else:
            product = ifc_export.create_product(obj, parent, ifcfile, ifcclass)
    if product:
        exobj = get_object(product, obj.Document)
        if exobj is None:
            shapemode = getattr(parent, "ShapeMode", DEFAULT_SHAPEMODE)
            newobj = create_object(product, obj.Document, ifcfile, shapemode, objecttype)
            new = True
        else:
            newobj = exobj
        create_relationship(obj, newobj, parent, product, ifcfile, mode)
    base = getattr(obj, "Base", None)
    if base:
        # make sure the base is used only by this object before deleting
        if base.InList != [obj]:
            base = None
    # handle layer
    if FreeCAD.GuiUp:
        import FreeCADGui

        autogroup = getattr(getattr(FreeCADGui, "draftToolBar", None), "autogroup", None)
        if autogroup is not None:
            layer = FreeCAD.ActiveDocument.getObject(autogroup)
            if hasattr(layer, "StepId"):
                ifc_layers.add_to_layer(newobj, layer)
    # aggregate dependent objects
    for child in obj.InList:
        if hasattr(child, "Host") and child.Host == obj:
            aggregate(child, newobj)
        elif hasattr(child, "Hosts") and obj in child.Hosts:
            aggregate(child, newobj)
    for child in getattr(obj, "Group", []):
        if newobj.IfcClass == "IfcGroup" and child in obj.Group:
            aggregate(child, newobj)
    delete = not (PARAMS.GetBool("KeepAggregated", False))
    if new and delete and base:
        obj.Document.removeObject(base.Name)
    label = obj.Label
    if new and delete:
        obj.Document.removeObject(obj.Name)
    if new:
        newobj.Label = label  # to avoid 001-ing the Label...
    return newobj


def deaggregate(obj, parent):
    """Removes a FreeCAD object form its parent"""

    ifcfile = get_ifcfile(obj)
    element = get_ifc_element(obj)
    if not element:
        return
    try:
        api_run("aggregate.unassign_object", ifcfile, products=[element])
    except:
        # older version of ifcopenshell
        api_run("aggregate.unassign_object", ifcfile, product=element)
    parent.Proxy.removeObject(parent, obj)


def get_ifctype(obj):
    """Returns a valid IFC type from an object"""

    if hasattr(obj, "Class"):
        if "ifc" in str(obj.Class).lower():
            return obj.Class
    if hasattr(obj, "IfcType") and obj.IfcType != "Undefined":
        return "Ifc" + obj.IfcType.replace(" ", "")
    dtype = Draft.getType(obj)
    if dtype in ["App::Part", "Part::Compound", "Array"]:
        return "IfcElementAssembly"
    if dtype in ["App::DocumentObjectGroup"]:
        return "IfcGroup"
    return "IfcBuildingElementProxy"


def get_subvolume(obj):
    """returns a subface + subvolume from a window object"""

    tempface = None
    tempobj = None
    tempshape = None
    if hasattr(obj, "Proxy") and hasattr(obj.Proxy, "getSubVolume"):
        tempshape = obj.Proxy.getSubVolume(obj)
    elif hasattr(obj, "Subvolume") and obj.Subvolume:
        tempshape = obj.Subvolume
    if tempshape:
        if len(tempshape.Faces) == 6:
            # We assume the standard output of ArchWindows
            faces = sorted(tempshape.Faces, key=lambda f: f.CenterOfMass.z)
            baseface = faces[0]
            ext = faces[-1].CenterOfMass.sub(faces[0].CenterOfMass)
            tempface = obj.Document.addObject("Part::Feature", "BaseFace")
            tempface.Shape = baseface
            tempobj = obj.Document.addObject("Part::Extrusion", "Opening")
            tempobj.Base = tempface
            tempobj.DirMode = "Custom"
            tempobj.Dir = FreeCAD.Vector(ext).normalize()
            tempobj.LengthFwd = ext.Length
        else:
            tempobj = obj.Document.addObject("Part::Feature", "Opening")
            tempobj.Shape = tempshape
    if tempobj:
        tempobj.recompute()
    return tempface, tempobj


def create_relationship(old_obj, obj, parent, element, ifcfile, mode=None):
    """Creates a relationship between an IFC object and a parent IFC object"""

    if isinstance(parent, (FreeCAD.DocumentObject, FreeCAD.Document)):
        parent_element = get_ifc_element(parent)
    else:
        parent_element = parent
    uprel = None
    # case 4: anything inside group
    if parent_element.is_a("IfcGroup"):
        # special case: adding a section plane to a grouo turns it into a drawing
        # and removes it from any containment
        if element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING":
            parent.ObjectType = "DRAWING"
            try:
                api_run("spatial.unassign_container", ifcfile, products=[parent_element])
            except:
                # older version of IfcOpenShell
                api_run("spatial.unassign_container", ifcfile, product=parent_element)
        # IFC objects can be part of multiple groups but we do the FreeCAD way here
        # and remove from any previous group
        for assignment in getattr(element, "HasAssignments", []):
            if assignment.is_a("IfcRelAssignsToGroup"):
                if element in assignment.RelatedObjects:
                    oldgroup = assignment.RelatingGr
                    try:
                        api_run("group.unassign_group", ifcfile, products=[element], group=oldgroup)
                    except:
                        # older version of IfcOpenShell
                        api_run("group.unassign_group", ifcfile, product=element, group=oldgroup)
        try:
            uprel = api_run("group.assign_group", ifcfile, products=[element], group=parent_element)
        except:
            # older version of IfcOpenShell
            uprel = api_run("group.assign_group", ifcfile, product=element, group=parent_element)
    # case 1: element inside spatiual structure
    elif parent_element.is_a("IfcSpatialStructureElement") and element.is_a("IfcElement"):
        # first remove the FreeCAD object from any parent
        if old_obj:
            for old_par in old_obj.InList:
                if hasattr(old_par, "Group") and old_obj in old_par.Group:
                    old_par.Group = [o for o in old_par.Group if o != old_obj]
            try:
                uprel = api_run("spatial.unassign_container", ifcfile, products=[element])
            except:
                # older version of IfcOpenShell
                uprel = api_run("spatial.unassign_container", ifcfile, product=element)
        if element.is_a("IfcOpeningElement"):
            uprel = api_run(
                "void.add_opening",
                ifcfile,
                opening=element,
                element=parent_element,
            )
        else:
            try:
                uprel = api_run(
                    "spatial.assign_container",
                    ifcfile,
                    products=[element],
                    relating_structure=parent_element,
                )
            except:
                # older version of ifcopenshell
                uprel = api_run(
                    "spatial.assign_container",
                    ifcfile,
                    product=element,
                    relating_structure=parent_element,
                )
    # case 2: door/window inside element
    # https://standards.buildingsmart.org/IFC/RELEASE/IFC4/ADD2_TC1/HTML/annex/annex-e/wall-with-opening-and-window.htm
    elif parent_element.is_a("IfcElement") and element.is_a() in [
        "IfcDoor",
        "IfcWindow",
    ]:
        if old_obj:
            tempface, tempobj = get_subvolume(old_obj)
            if tempobj:
                opening = ifc_export.create_product(tempobj, parent, ifcfile, "IfcOpeningElement")
                set_attribute(ifcfile, opening, "Name", "Opening")
                old_obj.Document.removeObject(tempobj.Name)
                if tempface:
                    old_obj.Document.removeObject(tempface.Name)
                api_run("void.add_opening", ifcfile, opening=opening, element=parent_element)
                api_run("void.add_filling", ifcfile, opening=opening, element=element)
        # windows must also be part of a spatial container
        try:
            api_run("spatial.unassign_container", ifcfile, products=[element])
        except:
            # old version of IfcOpenShell
            api_run("spatial.unassign_container", ifcfile, product=element)
        if parent_element.ContainedInStructure:
            container = parent_element.ContainedInStructure[0].RelatingStructure
            try:
                uprel = api_run(
                    "spatial.assign_container",
                    ifcfile,
                    products=[element],
                    relating_structure=container,
                )
            except:
                # old version of IfcOpenShell
                uprel = api_run(
                    "spatial.assign_container",
                    ifcfile,
                    product=element,
                    relating_structure=container,
                )
        elif parent_element.Decomposes:
            container = parent_element.Decomposes[0].RelatingObject
            try:
                uprel = api_run(
                    "aggregate.assign_object",
                    ifcfile,
                    products=[element],
                    relating_object=container,
                )
            except:
                # older version of ifcopenshell
                uprel = api_run(
                    "aggregate.assign_object",
                    ifcfile,
                    product=element,
                    relating_object=container,
                )
    # case 4: void element
    elif (parent_element.is_a("IfcElement") and element.is_a("IfcOpeningElement")) or (
        mode == "opening"
    ):
        uprel = api_run("void.add_opening", ifcfile, opening=element, element=parent_element)
    # case 3: element aggregated inside other element
    elif element.is_a("IfcProduct"):
        try:
            api_run("aggregate.unassign_object", ifcfile, products=[element])
        except:
            # older version of ifcopenshell
            api_run("aggregate.unassign_object", ifcfile, product=element)
        try:
            uprel = api_run(
                "aggregate.assign_object",
                ifcfile,
                products=[element],
                relating_object=parent_element,
            )
        except:
            # older version of ifcopenshell
            uprel = api_run(
                "aggregate.assign_object",
                ifcfile,
                product=element,
                relating_object=parent_element,
            )
    if hasattr(parent, "Proxy") and hasattr(parent.Proxy, "addObject"):
        parent.Proxy.addObject(parent, obj)
    return uprel


def get_elem_attribs(ifcentity):
    # This function can become pure IFC

    # usually info_ifcentity = ifcentity.get_info() would de the trick
    # the above could raise an unhandled exception on corrupted ifc files
    # in IfcOpenShell
    # see https://github.com/IfcOpenShell/IfcOpenShell/issues/2811
    # thus workaround

    info_ifcentity = {"id": ifcentity.id(), "class": ifcentity.is_a()}

    # get attrib keys
    attribs = []
    for anumber in range(20):
        try:
            attr = ifcentity.attribute_name(anumber)
        except Exception:
            break
        attribs.append(attr)

    # get attrib values
    for attr in attribs:
        try:
            value = getattr(ifcentity, attr)
        except Exception as e:
            value = "Error: {}".format(e)
            print(
                "DEBUG: The entity #{} has a problem on attribute {}: {}".format(
                    ifcentity.id(), attr, e
                )
            )
        info_ifcentity[attr] = value

    return info_ifcentity


def migrate_schema(ifcfile, schema):
    """migrates a file to a new schema"""

    # This function can become pure IFC

    newfile = ifcopenshell.file(schema=schema)
    migrator = ifcopenshell.util.schema.Migrator()
    table = {}
    for entity in ifcfile:
        new_entity = migrator.migrate(entity, newfile)
        table[entity.id()] = new_entity.id()
    return newfile, table


def remove_ifc_element(obj, delete_obj=False):
    """removes the IFC data associated with an object.
    If delete_obj is True, the FreeCAD object is also deleted"""

    # This function can become pure IFC

    ifcfile = get_ifcfile(obj)
    element = get_ifc_element(obj)
    if ifcfile and element:
        api_run("root.remove_product", ifcfile, product=element)
        if delete_obj:
            obj.Document.removeObject(obj.Name)
        return True
    return False


def get_orphan_elements(ifcfile):
    """returns a list of orphan products in an ifcfile"""

    products = ifcfile.by_type("IfcProduct")
    products = [p for p in products if not p.Decomposes]
    products = [p for p in products if not getattr(p, "ContainedInStructure", [])]
    products = [p for p in products if not hasattr(p, "VoidsElements") or not p.VoidsElements]
    # add control elements
    proj = ifcfile.by_type("IfcProject")[0]
    for rel in getattr(proj, "Declares", []):
        for ctrl in getattr(rel, "RelatedDefinitions", []):
            if ctrl.is_a("IfcControl"):
                products.append(ctrl)
    groups = []
    for o in products:
        for rel in getattr(o, "HasAssignments", []):
            if rel.is_a("IfcRelAssignsToGroup"):
                g = rel.RelatingGroup
                if (g not in products) and (g not in groups):
                    groups.append(g)
    products.extend(groups)
    return products


def get_group(project, name):
    """returns a group of the given type under the given IFC project. Creates it if needed"""

    if not project:
        return None
    if hasattr(project, "Group"):
        group = project.Group
    elif hasattr(project, "Objects"):
        group = project.Objects
    else:
        group = []
    for c in group:
        if c.isDerivedFrom("App::DocumentObjectGroupPython"):
            if c.Name == name:
                return c
    if hasattr(project, "Document"):
        doc = project.Document
    else:
        doc = project
    group = add_object(doc, otype="group", oname=name)
    group.Label = name.strip("Ifc").strip("Group")
    if hasattr(project.Proxy, "addObject"):
        project.Proxy.addObject(project, group)
    return group


def load_orphans(obj):
    """loads orphan objects from the given project object"""

    if isinstance(obj, FreeCAD.DocumentObject):
        doc = obj.Document
    else:
        doc = obj
    ifcfile = get_ifcfile(obj)
    shapemode = obj.ShapeMode
    elements = get_orphan_elements(ifcfile)
    objs = []
    for element in elements:
        nobj = create_object(element, doc, ifcfile, shapemode)
        objs.append(nobj)
    processed = assign_groups(elements, ifcfile)

    # put things under project. This is important so orphan elements still can find
    # their IFC file
    rest = [o for o in objs if o not in processed]
    if rest:
        project = get_project(ifcfile)
        if isinstance(project, FreeCAD.DocumentObject):
            for o in rest:
                project.Proxy.addObject(project, o)

    # TEST: Try recomputing
    QtCore.QTimer.singleShot(0, lambda: recompute(objs))


def remove_tree(objs):
    """Removes all given objects and their children, if not used by others"""

    if not objs:
        return
    doc = objs[0].Document
    nobjs = objs
    for obj in objs:
        for child in obj.OutListRecursive:
            if child not in nobjs:
                nobjs.append(child)
    deletelist = []
    for obj in nobjs:
        for par in obj.InList:
            if par not in nobjs:
                break
        else:
            deletelist.append(obj.Name)
    for n in deletelist:
        doc.removeObject(n)


def recompute(children):
    """Temporary function to recompute objects. Some objects don't get their
    shape correctly at creation"""
    doc = None
    for c in children:
        if c:
            c.touch()
            doc = c.Document
    if doc:
        doc.recompute()
