# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell 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 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 IfcOpenShell.  If not, see <http://www.gnu.org/licenses/>.

import json
import os
import time
from typing import Any, Literal, Union

import ifcopenshell
import ifcopenshell.ifcopenshell_wrapper as ifcopenshell_wrapper
import ifcopenshell.util.attribute

# This is highly experimental and incomplete, however, it may work for simple datasets.

cwd = os.path.dirname(os.path.realpath(__file__))
IFC_SCHEMA = Literal["IFC2X3", "IFC4", "IFC4X3"]


def get_fallback_schema(version: str) -> IFC_SCHEMA:
    """Fallback to the schema version we do have docs and mapping for.

    Needed to support IFC versions like 4X3_RC1, 4X1 etc.

    :param version: Typically a string from ``ifcopenshell.file.schema_identifier``, e.g. IFC4X3_ADD2
    """
    if version.startswith("IFC4X3"):
        version = "IFC4X3"
    elif version.startswith("IFC4"):
        version = "IFC4"
    elif version.startswith("IFC2X3"):
        version = "IFC2X3"
    else:
        assert False, f"Unexpected schema version: {version}."
    return version


def get_declaration(element: ifcopenshell.entity_instance):
    """Get the schema declaration of an actively used entity instance

    IFC models are made out of instances (e.g. with a STEP ID) of entities
    (e.g. IfcWall). Those entities are defined through a **Schema
    Declaration**.

    **Schema Declaration** objects can be used to query information about the
    IFC schema itself, such as data types, enumeration values, and inheritance.

    :param element: Any instance, typically from a loaded or created IFC model

    Example:

    .. code:: python

        wall = model.createIfcWall()
        declaration = ifcopenshell.util.schema.get_declaration(wall)
        print(declaration.name()) # IfcWall
        print(declaration.is_abstract()) # False
        print(declaration.supertype().name()) # IfcBuildingElement
    """
    return element.wrapped_data.declaration().as_entity()


def is_a(declaration: ifcopenshell.ifcopenshell_wrapper.declaration, ifc_class: str) -> bool:
    """Checks if a schema declaration is a class

    :param declaration: The declaration from the schema.
    :param ifc_class: A case insensitive IFC class name (e.g. IfcRoot)
    :return: True is the declaration is of that class

    Example:

    .. code:: python

        wall = model.createIfcWall()
        declaration = ifcopenshell.util.schema.get_declaration(wall)
        ifcopenshell.util.schema.is_a(declaration, "IfcRoot") # True
    """
    return declaration._is(ifc_class)


def get_supertypes(
    declaration: ifcopenshell.ifcopenshell_wrapper.entity,
) -> list[ifcopenshell.ifcopenshell_wrapper.entity]:
    """Gets a list of supertype declarations

    :param declaration: The declaration from the schema, as an entity.
    :return: A list of supertypes in order from parent to grandparent.

    Example:

    .. code:: python

        wall = model.createIfcWall()
        results = ifcopenshell.util.schema.get_supertypes(wall.wrapped_data.declaration().as_entity())
        # [<entity IfcBuildingElement>, <entity IfcElement>, ..., <entity IfcRoot>]
    """
    results = []
    while True:
        if not (declaration := declaration.supertype()):
            break
        results.append(declaration)
    return results


def get_subtypes(
    declaration: ifcopenshell.ifcopenshell_wrapper.entity,
) -> list[ifcopenshell.ifcopenshell_wrapper.entity]:
    """Get a flat list of subtype declarations, recursively.

    Abstract classes are skipped.

    Inconsistently, the declaration itself is also added to this list. This
    should be fixed exclude the declaration itself.

    :param declaration: The declaration from the schema, as an entity.
    :return: A list of subtypes in order from child to grandchild.

    .. code:: python

        schema = ifcopenshell.schema_by_name("IFC4")
        declaration = schema.declaration_by_name("IfcFlowSegment")
        print(ifcopenshell.util.schema.get_subtypes(declaration))
        [<entity IfcFlowSegment>, <entity IfcCableCarrierSegment>, ..., <entity IfcPipeSegment>]
    """

    def get_classes(decl: ifcopenshell_wrapper.entity) -> list[ifcopenshell_wrapper.entity]:
        results: list[ifcopenshell_wrapper.entity] = []
        if not decl.is_abstract():
            results.append(decl)
        for subtype in decl.subtypes():
            results.extend(get_classes(subtype))
        return results

    return get_classes(declaration)


def reassign_class(
    ifc_file: Union[ifcopenshell.file, None], element: ifcopenshell.entity_instance, new_class: str
) -> ifcopenshell.entity_instance:
    """
    Attempts to change the class (entity name) of `element` to `new_class` by
    removing element and recreating a similar instance of type `new_class`
    with the same id.

    In certain cases it may affect the structure of inversely related instances:
    - Multiple occurrences of reassigned instance within the same aggregate
      (such as start and end-point of polyline)
    - Occurrences of reassigned instance within an ordered aggregate
      (such as IfcRelNests)

    It's unlikely that this affects real-world usage of this function.

    :raises ValueError: If ``new_class`` does not exist in the provided file schema.
    """

    if element.is_a() == new_class:
        return element

    if not ifc_file:
        ifc_file = element.file

    schema = ifcopenshell_wrapper.schema_by_name(ifc_file.schema_identifier)
    try:
        declaration = schema.declaration_by_name(new_class)
    except RuntimeError:
        raise ValueError(
            f"Class of {element} could not be changed to {new_class} as the class does not exist in schema {ifc_file.schema_identifier}."
        )

    info = element.get_info()

    new_attributes = {}
    for attribute in declaration.all_attributes():
        name = attribute.name()
        old_attribute = info.get(name, None)
        if old_attribute:
            if ifcopenshell.util.attribute.get_primitive_type(attribute) == "enum":
                if old_attribute in ifcopenshell.util.attribute.get_enum_items(attribute):
                    new_attributes[name] = old_attribute
            else:
                new_attributes[name] = old_attribute

    inverse_pairs = ifc_file.get_inverse(element, allow_duplicate=True, with_attribute_indices=True)
    ifc_file.remove(element)

    try:
        new_element = ifc_file.create_entity(new_class, id=info["id"], **new_attributes)
    except:
        print(f"Class of {element} could not be changed to {new_class}")
        old_class = info.pop("type")
        return ifc_file.create_entity(old_class, **info)

    for inverse_pair in inverse_pairs:
        inverse, index = inverse_pair
        if inverse[index] is None:
            inverse[index] = new_element
        elif isinstance(inverse[index], tuple):
            item = list(inverse[index])
            item.append(new_element)
            inverse[index] = item

    return new_element


class BatchReassignClass:
    def __init__(self, file: ifcopenshell.file):
        self.file = file
        self.purge()

    def reassign(self, element: ifcopenshell.entity_instance, new_class: str) -> ifcopenshell.entity_instance:
        try:
            new_element = self.file.create_entity(new_class)
        except:
            print(f"Class of {element} could not be changed to {new_class}")
            return element
        new_attributes = [new_element.attribute_name(i) for i, attribute in enumerate(new_element)]
        for i, attribute in enumerate(element):
            try:
                new_element[new_attributes.index(element.attribute_name(i))] = attribute
            except:
                continue
        for inverse_pair in self.file.get_inverse(element, allow_duplicate=True, with_attribute_indices=True):
            inverse, index = inverse_pair
            self.replacements.setdefault(inverse, {}).setdefault(index, {})[element] = new_element
        self.to_delete.add(element)
        return new_element

    def unbatch(self):
        for inverse, replacements in self.replacements.items():
            for index, element_map in replacements.items():
                value = inverse[index]
                new = inverse.walk(lambda x: True, lambda v: element_map.get(v, v), value)
                if value != new:
                    inverse[index] = new

        for element in self.to_delete:
            self.file.remove(element)
        self.purge()

    def purge(self) -> None:
        # mapping {inverse: {attribute_index: {old_element: new_element} } }
        self.replacements: dict[
            ifcopenshell.entity_instance, dict[int, dict[ifcopenshell.entity_instance, ifcopenshell.entity_instance]]
        ] = {}
        self.to_delete: set[ifcopenshell.entity_instance] = set()


class Migrator:
    migrated_ids: dict[int, int]
    attribute_overrides: dict[int, dict[int, str]]

    def __init__(self):
        self.migrated_ids = {}
        self.attribute_overrides = {}
        self.class_4_to_2x3 = json.load(open(os.path.join(cwd, "class_4_to_2x3.json"), "r"))
        self.class_2x3_to_4 = json.load(open(os.path.join(cwd, "class_2x3_to_4.json"), "r"))

        # IFC classes, and their IFC attributes mapping
        self.attributes_mapping = {
            ("IFC4", "IFC2X3"): json.load(open(os.path.join(cwd, "attribute_4_to_2x3.json"), "r")),
            ("IFC4X3", "IFC4"): json.load(open(os.path.join(cwd, "attribute_4x3_to_4.json"), "r")),
        }

        self.default_values = {
            "ChangeAction": "NOCHANGE",
            "CompositionType": "ELEMENT",
            "CrossSectionArea": 1,
            "DataValue": 0,
            "DefinedValues": [0],
            "DefiningValues": [0],
            "DestabilizingLoad": False,
            "Edition": "",
            "EndParam": 1.0,
            "EnumerationValues": [0],
            "GeodeticDatum": "",
            "Intent": "",
            "IsHeading": False,
            "ListValues": [0],
            "LongitudinalBarCrossSectionArea": 1,
            "LongitudinalBarNominalDiameter": 1,
            "LongitudinalBarSpacing": 1,
            "Name": "",
            "NominalDiameter": 1,
            "PredefinedType": "NOTDEFINED",
            "RowCells": [0],
            "SequenceType": "NOTDEFINED",
            "Source": "",
            "StartParam": 0.0,
            "TransverseBarCrossSectionArea": 1,
            "TransverseBarNominalDiameter": 1,
            "TransverseBarSpacing": 1,
            # Manual additions from experience
            "InteriorOrExteriorSpace": "NOTDEFINED",
            "AssemblyPlace": "NOTDEFINED",  # See bug https://github.com/Autodesk/revit-ifc/issues/395
        }
        self.default_entities = {
            "CurrentValue": None,
            "DepreciatedValue": None,
            "Jurisdiction": None,
            "OriginalValue": None,
            "Owner": None,
            "OwnerHistory": None,
            "Position": None,
            "PropertyReference": None,
            "RepresentationContexts": None,
            "ResponsiblePerson": None,
            "ResponsiblePersons": None,
            "Rows": None,
            "TotalReplacementCost": None,
            "UnitsInContext": None,
            "User": None,
        }

    def preprocess(self, old_file: ifcopenshell.file, new_file: ifcopenshell.file) -> None:
        new_file.assign_header_from(old_file)
        to_delete = set()

        if old_file.schema == "IFC2X3" and new_file.schema == "IFC4":
            # IfcCalendarDate is deprecated in IFC4
            for element in old_file.by_type("IfcCalendarDate"):
                for inverse, attribute_index in old_file.get_inverse(
                    element, allow_duplicate=True, with_attribute_indices=True
                ):
                    self.attribute_overrides.setdefault(inverse.id(), {})[
                        attribute_index
                    ] = f"{element[2]}-{element[1]}-{element[0]}"
                to_delete.add(element)

        if old_file.schema == "IFC4" and new_file.schema == "IFC4X3":
            # IfcPresentationStyleAssignment is deprecated
            for assignment in old_file.by_type("IfcPresentationStyleAssignment"):
                for styled_item in old_file.get_inverse(assignment):
                    if not styled_item.is_a("IfcStyledItem"):
                        continue
                    styled_item.Styles = [s for s in styled_item.Styles if s.is_a("IfcPresentationStyle")] + list(
                        assignment.Styles
                    )
                to_delete.add(assignment)

        for element in to_delete:
            old_file.remove(element)

    def migrate(
        self, element: ifcopenshell.entity_instance, new_file: ifcopenshell.file
    ) -> ifcopenshell.entity_instance:
        if element.id() == 0:
            ifc_class = element.is_a()
            if ifc_class == "IfcCountMeasure" and new_file.schema == "IFC4X3":
                value = element.wrappedValue
                if isinstance(value, float):
                    ifc_class = "IfcNumericMeasure"
            return new_file.create_entity(ifc_class, element.wrappedValue)
        try:
            return new_file.by_id(self.migrated_ids[element.id()])
        except:
            pass
        # print("Migrating", element)
        schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(new_file.schema_identifier)
        new_element = self.migrate_class(element, new_file)
        # print("Migrated class from {} to {}".format(element, new_element))
        new_element_schema = schema.declaration_by_name(new_element.is_a())
        if not hasattr(new_element_schema, "all_attributes"):
            return element  # The element has no attributes, so migration is done
        new_element = self.migrate_attributes(element, new_file, new_element, new_element_schema)
        self.migrated_ids[element.id()] = new_element.id()
        return new_element

    def migrate_class(
        self, element: ifcopenshell.entity_instance, new_file: ifcopenshell.file
    ) -> ifcopenshell.entity_instance:
        ifc_class = element.is_a()
        if ifc_class == "IfcQuantityCount" and new_file.schema == "IFC4X3":
            # 3 IfcPhysicalSimpleQuantity Value
            value = element[3]
            if isinstance(value, float):
                ifc_class = "IfcQuantityNumber"
        try:
            new_element = new_file.create_entity(ifc_class)
        except:
            # The element does not exist in this schema
            # Complex migration is not yet supported (e.g. polygonal face set to faceted brep)
            if new_file.schema == "IFC2X3":
                new_element = new_file.create_entity(self.class_4_to_2x3[ifc_class])
            elif new_file.schema == "IFC4":
                new_element = new_file.create_entity(self.class_2x3_to_4[ifc_class])
        return new_element

    def migrate_attributes(
        self,
        element: ifcopenshell.entity_instance,
        new_file: ifcopenshell.file,
        new_element: ifcopenshell.entity_instance,
        new_element_schema: ifcopenshell_wrapper.declaration,
    ) -> ifcopenshell.entity_instance:
        for attribute_index, value in self.attribute_overrides.get(element.id(), {}).items():
            new_element[attribute_index] = value
        for i, attribute in enumerate(new_element_schema.all_attributes()):
            if new_element_schema.derived()[i]:
                continue
            self.migrate_attribute(attribute, element, new_file, new_element, new_element_schema)
        return new_element

    def find_equivalent_attribute(
        self,
        new_element: ifcopenshell.entity_instance,
        attribute: ifcopenshell_wrapper.attribute,
        element: ifcopenshell.entity_instance,
        attributes_mapping: dict[str, dict[str, str]],
        reverse_mapping: bool = False,
    ) -> Union[Any, None]:
        # print("Searching for an equivalent", element, new_element, attribute.name())
        ifc_class = new_element.is_a()
        attr_name = attribute.name()
        try:
            if reverse_mapping:
                equivalent_map = attributes_mapping[ifc_class]
                equivalent = list(equivalent_map.keys())[list(equivalent_map.values()).index(attr_name)]
            else:
                equivalent = attributes_mapping[ifc_class][attr_name]
            if hasattr(element, equivalent):
                # print("Equivalent found", equivalent)
                return getattr(element, equivalent)
            else:
                return
        except Exception as e:
            if (
                ifc_class == "IfcQuantityNumber"
                and attr_name == "NumberValue"
                and new_element.file.schema == "IFC4X3"
                and element.is_a("IfcQuantityCount")
            ):
                # 3 IfcPhysicalSimpleQuantity Value
                return element[3]

            print(
                "Unable to find equivalent attribute of {} to migrate from {} to {}".format(
                    attr_name, element, new_element
                )
            )
            raise e

    def migrate_attribute(
        self,
        attribute: ifcopenshell_wrapper.attribute,
        element: ifcopenshell.entity_instance,
        new_file: ifcopenshell.file,
        new_element: ifcopenshell.entity_instance,
        new_element_schema: ifcopenshell_wrapper.declaration,
    ) -> None:
        # NOTE: `attribute` is an attribute in new file schema
        # print("Migrating attribute", element, new_element, attribute.name())
        old_file = element.wrapped_data.file
        if hasattr(element, attribute.name()):
            value = getattr(element, attribute.name())
            # print("Attribute names matched", value)

        elif new_file.schema == "IFC2X3" and old_file.schema == "IFC4":
            # IFC4 to IFC2X3: We know the IFC2X3 attribute name, but not its IFC4 equivalent
            try:
                value = self.find_equivalent_attribute(
                    new_element, attribute, element, self.attributes_mapping[("IFC4", "IFC2X3")], reverse_mapping=True
                )
            except:  # We tried our best
                return

        elif new_file.schema == "IFC4" and old_file.schema == "IFC2X3":
            # IFC2X3 to IFC4: We know the IFC4 attribute name, but not its IFC2X3 equivalent
            try:
                value = self.find_equivalent_attribute(
                    new_element, attribute, element, self.attributes_mapping[("IFC4", "IFC2X3")]
                )
            except:  # We tried our best
                return

        elif new_file.schema == "IFC4X3" and old_file.schema == "IFC4":
            try:
                value = self.find_equivalent_attribute(
                    new_element, attribute, element, self.attributes_mapping[("IFC4X3", "IFC4")]
                )
            except:  # We tried our best
                return

        elif new_file.schema == "IFC4" and old_file.schema == "IFC4X3":
            try:
                value = self.find_equivalent_attribute(
                    new_element, attribute, element, self.attributes_mapping[("IFC4X3", "IFC4")], reverse_mapping=True
                )
            except:  # We tried our best
                return

        try:
            value
        except UnboundLocalError:
            print(
                f"Couldn't match attribute {attribute.name()} by name to migrate from {element} "
                f"to {new_element} and there is no special mapping to handle migration "
                f"from {old_file.schema} -> {new_file.schema}"
            )
            return

        # print("Continuing migration of {} to migrate from {} to {}".format(attribute.name(), element, new_element))
        if value is None and not attribute.optional():
            value = self.generate_default_value(attribute, new_file)
            if value is None:
                print("Failed to generate default value for {} on {}".format(attribute.name(), element))
        elif isinstance(value, ifcopenshell.entity_instance):
            value = self.migrate(value, new_file)
        elif isinstance(value, (list, tuple)):
            if value and isinstance(value[0], ifcopenshell.entity_instance):
                new_value = []
                for item in value:
                    new_value.append(self.migrate(item, new_file))
                value = new_value
        if value is not None:
            setattr(new_element, attribute.name(), value)

    def generate_default_value(self, attribute: ifcopenshell_wrapper.attribute, new_file: ifcopenshell.file) -> Any:
        if attribute.name() in self.default_values:
            return self.default_values[attribute.name()]
        elif attribute.name() == "OwnerHistory":
            self.default_entities[attribute.name()] = new_file.create_entity(
                "IfcOwnerHistory",
                **{
                    "OwningUser": new_file.create_entity(
                        "IfcPersonAndOrganization",
                        **{
                            "ThePerson": new_file.create_entity("IfcPerson"),
                            "TheOrganization": new_file.create_entity(
                                "IfcOrganization", **{"Name": "IfcOpenShell Migrator"}
                            ),
                        },
                    ),
                    "OwningApplication": new_file.create_entity(
                        "IfcApplication",
                        **{
                            "ApplicationDeveloper": new_file.create_entity(
                                "IfcOrganization", **{"Name": "IfcOpenShell Migrator"}
                            ),
                            "Version": "Works for me",
                            "ApplicationFullName": "IfcOpenShell Migrator",
                            "ApplicationIdentifier": "IfcOpenShell Migrator",
                        },
                    ),
                    "ChangeAction": "NOCHANGE",
                    "CreationDate": int(time.time()),
                },
            )
        return self.default_entities.get(attribute.name(), None)
