# ***************************************************************************
# *                                                                         *
# *   Copyright (c) 2022 Yorik van Havre <yorik@uncreated.net>              *
# *                                                                         *
# *   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                                                                   *
# *                                                                         *
# ***************************************************************************

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

import os

# heavyweight libraries - ifc_tools should always be lazy loaded

import FreeCAD
import Draft
import Arch
from importers import exportIFC
from importers import exportIFCHelper

import ifcopenshell
from ifcopenshell import geom
from ifcopenshell import api
from ifcopenshell import template
from ifcopenshell.util import element
from ifcopenshell.util import attribute
from ifcopenshell.util import schema
from ifcopenshell.util import placement
from ifcopenshell.util import unit

from nativeifc import ifc_objects
from nativeifc import ifc_viewproviders
from nativeifc import ifc_import
from nativeifc import ifc_layers
from nativeifc import ifc_status

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 bbuilding 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 bbuilding 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 not "Proxy" in document.PropertiesList:
        document.addProperty("App::PropertyPythonObject", "Proxy")
    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 not "IfcFilePath" in proj.PropertiesList:
        proj.addProperty("App::PropertyFile", "IfcFilePath", "Base", d)
    if not "Modified" in proj.PropertiesList:
        proj.addProperty("App::PropertyBool", "Modified", "Base")
    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 not "Schema" in proj.PropertiesList:
        proj.addProperty("App::PropertyEnumeration", "Schema", "Base")
    # 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()
    if user:
        person = api_run("owner.add_person", ifcfile, family_name=user)
    org = param.GetString("prefCompany", "")
    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]])
    application = api_run(
        "owner.add_application",
        ifcfile,
        application_full_name=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 unit, as per https://blenderbim.org/docs-python/autoapi/ifcopenshell/api/unit/assign_unit/index.html
    # TODO allow to set this at creation, from the current FreeCAD units schema
    api_run("unit.assign_unit", ifcfile)
    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):
    """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(), ifcentity.Name
    )
    FreeCAD.Console.PrintLog(s)
    obj = add_object(document)
    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"):
            obj.ViewObject.DisplayMode = "Wireframe"
    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 not element.id() 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)
    return result


def assign_groups(children):
    """Fill the groups inthis list"""

    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)
            for rel in getattr(child, mode):
                for elem in rel.RelatedObjects:
                    elobj = get_object(elem)
                    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


def get_children(
    obj, ifcfile=None, only_structure=False, assemblies=True, expand=False, iftype=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 iftype:
        result = [r for r in result if r.is_a(ifctype)]
    return result


def get_object(element, document=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) == 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 project.IfcFilePath:
            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
    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' or None (normal object)"""

    if not document:
        return None
    proxy = ifc_objects.ifc_object(otype)
    if otype == "group":
        proxy = None
        ftype = "App::DocumentObjectGroupPython"
    elif otype == "material":
        ftype = "App::MaterialObjectPython"
    elif otype == "layer":
        ftype = "App::FeaturePython"
    else:
        ftype = "Part::FeaturePython"
    if otype == "project":
        vp = ifc_viewproviders.ifc_vp_document()
    elif otype == "group":
        vp = ifc_viewproviders.ifc_vp_group()
    elif otype == "material":
        vp = ifc_viewproviders.ifc_vp_material()
    elif otype == "layer":
        vp = None
    else:
        vp = ifc_viewproviders.ifc_vp_object()
    obj = document.addObject(ftype, oname, proxy, vp, False)
    if obj.ViewObject and otype == "layer":
        from draftviewproviders import view_layer  # lazy import

        view_layer.ViewProviderLayer(obj.ViewObject)
        obj.ViewObject.addProperty("App::PropertyBool", "HideChildren", "Layer")
        obj.ViewObject.HideChildren = True
    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")
    if "ShapeMode" not in obj.PropertiesList:
        obj.addProperty("App::PropertyEnumeration", "ShapeMode", "Base")
        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")
    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")
                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")
                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")
                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")
            setattr(obj, attr, value)
        elif data_type == "boolean":
            if attr not in obj.PropertiesList:
                obj.addProperty("App::PropertyBool", attr, "IFC")
            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:
                    # value = create_object(value, obj.Document)
                    obj.addProperty("App::PropertyLink", attr, "IFC")
                # setattr(obj, attr, value)
        elif isinstance(value, (list, tuple)) and value:
            if isinstance(value[0], ifcopenshell.entity_instance):
                if links:
                    if attr not in obj.PropertiesList:
                        # nvalue = []
                        # for elt in value:
                        #    nvalue.append(create_object(elt, obj.Document))
                        obj.addProperty("App::PropertyLinkList", attr, "IFC")
                    # setattr(obj, attr, nvalue)
        elif data_type == "enum":
            if attr not in obj.PropertiesList:
                obj.addProperty("App::PropertyEnumeration", attr, "IFC")
            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)
        else:
            if attr not in obj.PropertiesList:
                obj.addProperty("App::PropertyString", attr, "IFC")
            if value is not None:
                setattr(obj, attr, str(value))
    # 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")]
    # skip annotations for now
    elements = [e for e in elements if not e.is_a("IfcAnnotation")]
    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

    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
    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 getattr(element, attribute) != value:
            FreeCAD.Console.PrintLog(
                "Changing IFC attribute value of "
                + str(attribute)
                + ": "
                + str(value)
                + "\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"):
            if isinstance(colors[0], (tuple, list)):
                vobj.ShapeColor = colors[0][:3]
                # do not set transparency when the object has more than one color
                #if len(colors[0]) > 3:
                #    vobj.Transparency = int(colors[0][3] * 100)
            else:
                vobj.ShapeColor = colors[:3]
                if len(colors) > 3:
                    vobj.Transparency = int(colors[3] * 100)
        if hasattr(vobj, "DiffuseColor"):
            # strip out transparency value because it currently gives ugly
            # results in FreeCAD when combining transparent and non-transparent objects
            if all([len(c) > 3 and c[3] != 0 for c in colors]):
                vobj.DiffuseColor = colors
            else:
                vobj.DiffuseColor = [c[:3] for c in colors]


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)
    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 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):
    """Takes any FreeCAD object and aggregates it to an existing IFC object"""

    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)
    product = None
    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
        new = False
    else:
        product = create_product(obj, parent, ifcfile)
        shapemode = getattr(parent, "ShapeMode", DEFAULT_SHAPEMODE)
        newobj = create_object(product, obj.Document, ifcfile, shapemode)
        new = True
    create_relationship(obj, newobj, parent, product, ifcfile)
    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:
            #op = create_product(child, newobj, ifcfile, ifcclass="IfcOpeningElement")
            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 create_product(obj, parent, ifcfile, ifcclass=None):
    """Creates an IFC product out of a FreeCAD object"""

    name = obj.Label
    description = getattr(obj, "Description", None)
    if not ifcclass:
        ifcclass = get_ifctype(obj)
    representation, placement = create_representation(obj, ifcfile)
    product = api_run("root.create_entity", ifcfile, ifc_class=ifcclass, name=name)
    set_attribute(ifcfile, product, "Description", description)
    set_attribute(ifcfile, product, "ObjectPlacement", placement)
    # TODO below cannot be used at the moment because the ArchIFC exporter returns an
    # IfcProductDefinitionShape already and not an IfcShapeRepresentation
    # api_run("geometry.assign_representation", ifcfile, product=product, representation=representation)
    set_attribute(ifcfile, product, "Representation", representation)
    # TODO treat subtractions/additions
    return product


def create_representation(obj, ifcfile):
    """Creates a geometry representation for the given object"""

    # TEMPORARY use the Arch exporter
    # TODO this is temporary. We should rely on ifcopenshell for this with:
    # https://blenderbim.org/docs-python/autoapi/ifcopenshell/api/root/create_entity/index.html
    # a new FreeCAD 'engine' should be added to:
    # https://blenderbim.org/docs-python/autoapi/ifcopenshell/api/geometry/index.html
    # that should contain all typical use cases one could have to convert FreeCAD geometry
    # to IFC.

    # setup exporter - TODO do that in the module init
    exportIFC.clones = {}
    exportIFC.profiledefs = {}
    exportIFC.surfstyles = {}
    exportIFC.shapedefs = {}
    exportIFC.ifcopenshell = ifcopenshell
    exportIFC.ifcbin = exportIFCHelper.recycler(ifcfile, template=False)
    prefs, context = get_export_preferences(ifcfile)
    representation, placement, shapetype = exportIFC.getRepresentation(
        ifcfile, context, obj, preferences=prefs
    )
    return representation, placement


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"]:
        ifctype = "IfcGroup"
    return "IfcBuildingElementProxy"


def get_export_preferences(ifcfile):
    """returns a preferences dict for exportIFC"""

    prefs = exportIFC.getPreferences()
    prefs["SCHEMA"] = ifcfile.wrapped_data.schema_name()
    s = ifcopenshell.util.unit.calculate_unit_scale(ifcfile)
    # the above lines yields meter -> file unit scale factor. We need mm
    prefs["SCALE_FACTOR"] = 0.001 / s
    context = ifcfile[
        get_body_context_ids(ifcfile)[0]
    ]  # we take the first one (first found subcontext)
    return prefs, context


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):
    """Creates a relationship between an IFC object and a parent IFC object"""

    parent_element = get_ifc_element(parent)
    # case 1: element inside spatiual structure
    if parent_element.is_a("IfcSpatialStructureElement") and element.is_a("IfcElement"):
        # first remove the FreeCAD object from any parent
        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:
            api_run("spatial.unassign_container", ifcfile, products=[element])
        except:
            # older version of IfcOpenShell
            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: dooe/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",
    ]:
        tempface, tempobj = get_subvolume(old_obj)
        if tempobj:
            opening = create_product(tempobj, parent, ifcfile, "IfcOpeningElement")
            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 3: element aggregated inside other element
    else:
        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, "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
        # print(attr)
        attribs.append(attr)

    # get attrib values
    for attr in attribs:
        try:
            value = getattr(ifcentity, attr)
        except Exception as e:
            # print(e)
            value = "Error: {}".format(e)
            print(
                "DEBUG: The entity #{} has a problem on attribute {}: {}".format(
                    ifcentity.id(), attr, e
                )
            )
        # print(value)
        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):
    """removes the IFC data associated with an object"""

    # 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)
        return True
    return False


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

    products = ifcfile.by_type("IfcElement")
    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
    ]
    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 FreeCAD.GuiUp:
    #    group.ViewObject.ShowInTree = PARAMS.GetBool("ShowDataGroups", False)
    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)
    for element in elements:
        create_object(element, doc, ifcfile, shapemode)


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 not child 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)


