#!/usr/bin/env python3

# IfcDiff - Compare IFCs
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcDiff.
#
# IfcDiff 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.
#
# IfcDiff 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 IfcDiff.  If not, see <http://www.gnu.org/licenses/>.

# This can be packaged with `pyinstaller --onefile --clean --icon=icon.ico ifcdiff.py`

import time
import json
import logging
import argparse
import numpy as np
import multiprocessing
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.util.element
import ifcopenshell.util.placement
import ifcopenshell.util.classification
import ifcopenshell.util.selector
from deepdiff import DeepDiff


class IfcDiff:
    """Main IfcDiff application

    If you are using IfcDiff as a library, this is the class you should use.

    :param old: IFC file object for the old model
    :type old: ifcopenshell.file.file
    :param new: IFC file object for the old model
    :type new: ifcopenshell.file.file
    :param relationships: List of relationships to check. None means that only
        geometry is compared.
    :type relationships: list[string]
    :param is_shallow: True if you want only the first difference to be listed.
        False if you want all differences to be checked. Choosing False means
        that comparisons will take longer.
    :type is_shallow: bool
    :param filter_elements: An IFC filter query if you only want to compare a
        subset of elements. For example: ``.IfcWall`` to only compare walls.
    :type filter_elements: string

    Example::

        from ifcdiff import IfcDiff

        ifc_diff = IfcDiff("/path/to/old.ifc", "/path/to/new.ifc", "/path/to/diff.json")
        ifc_diff.diff()
        print(ifc_diff.change_register)
        ifc_diff.export()
    """

    def __init__(self, old, new, relationships=None, is_shallow=True, filter_elements=None):
        self.old = old
        self.new = new
        self.change_register = {}
        self.representation_ids = {}
        self.relationships = relationships or ["geometry"]
        self.precision = 1e-4
        self.is_shallow = is_shallow
        self.filter_elements = filter_elements

    def diff(self):
        logging.disable(logging.CRITICAL)

        self.precision = self.get_precision()

        if self.filter_elements:
            selector = ifcopenshell.util.selector.Selector()
            old_elements = set(e.GlobalId for e in selector.parse(self.old, self.filter_elements))
            new_elements = set(e.GlobalId for e in selector.parse(self.new, self.filter_elements))
        else:
            old_elements = self.old.by_type("IfcElement")
            if self.old.schema == "IFC2X3":
                old_elements += self.old.by_type("IfcSpatialStructureElement")
            else:
                old_elements += self.old.by_type("IfcSpatialElement")
            old_elements = set(e.GlobalId for e in old_elements if not e.is_a("IfcFeatureElement"))
            new_elements = self.new.by_type("IfcElement")
            if self.new.schema == "IFC2X3":
                new_elements += self.new.by_type("IfcSpatialStructureElement")
            else:
                new_elements += self.new.by_type("IfcSpatialElement")
            new_elements = set(e.GlobalId for e in new_elements if not e.is_a("IfcFeatureElement"))

        print(" - {} item(s) are in the old model".format(len(old_elements)))
        print(" - {} item(s) are in the new model".format(len(new_elements)))

        self.deleted_elements = old_elements - new_elements
        self.added_elements = new_elements - old_elements
        same_elements = new_elements - self.added_elements
        total_same_elements = len(same_elements)

        print(" - {} item(s) were added".format(len(self.added_elements)))
        print(" - {} item(s) were deleted".format(len(self.deleted_elements)))
        print(" - {} item(s) are common to both models".format(total_same_elements))

        total_diffed = 0

        potential_old_changes = []
        potential_new_changes = []

        should_check_attributes = False
        should_check_geometry = False
        should_check_other = False

        for relationship in self.relationships:
            if relationship == "attributes":
                should_check_attributes = True
            elif relationship == "geometry":
                should_check_geometry = True
            else:
                should_check_other = True

        for global_id in same_elements:
            total_diffed += 1
            if total_diffed % 250 == 0:
                print("{}/{} diffed ...".format(total_diffed, total_same_elements), end="\r", flush=True)
            old = self.old.by_id(global_id)
            new = self.new.by_id(global_id)
            if should_check_attributes:
                if self.diff_element(old, new) and self.is_shallow:
                    continue
            if should_check_other:
                if self.diff_element_relationships(old, new) and self.is_shallow:
                    continue
            if should_check_geometry:
                # Option 1: check everything heuristically using the iterator (seems faster)
                potential_old_changes.append(old)
                potential_new_changes.append(new)
                # Option 2: check first using Python, then fallback to iterator (twice as slow)
                # diff = self.diff_element_basic_geometry(old, new)
                # if diff:
                #    self.change_register.setdefault(new.GlobalId, {}).update({"geometry_changed": True})
                # else:
                #    potential_old_changes.append(old)
                #    potential_new_changes.append(new)

        print(" - {} item(s) had simple changes".format(len(self.change_register.keys())))

        if potential_old_changes:
            print(" - {} item(s) are queued for a detailed geometry check".format(len(potential_old_changes)))
            print("... processing old shapes ...")
            old_shapes = self.summarise_shapes(self.old, potential_old_changes)
            print("... processing new shapes ...")
            new_shapes = self.summarise_shapes(self.new, potential_new_changes)
            print("... comparing shapes ...")
            for global_id, old_shape in old_shapes.items():
                new_shape = new_shapes.get(global_id, None)
                if not new_shape:
                    self.change_register.setdefault(global_id, {}).update({"geometry_changed": True})
                    continue
                del new_shapes[global_id]
                diff = DeepDiff(old_shape, new_shape, math_epsilon=1e-5)
                if diff:
                    self.change_register.setdefault(global_id, {}).update({"geometry_changed": True})
                    continue

            for global_id in new_shapes.keys():
                self.change_register.setdefault(global_id, {}).update({"geometry_changed": True})

        print(" - {} item(s) were changed".format(len(self.change_register.keys())))

        logging.disable(logging.NOTSET)

    def summarise_shapes(self, ifc, elements):
        shapes = {}
        iterator = ifcopenshell.geom.iterator(
            self.get_settings(ifc), ifc, multiprocessing.cpu_count(), include=elements
        )
        valid_file = iterator.initialize()
        while True:
            shape = iterator.get()
            element = ifc.by_id(shape.id)
            geometry = shape.geometry
            shapes[element.GlobalId] = {
                "total_verts": len(geometry.verts),
                "sum_verts": sum(geometry.verts),
                "min_vert": min(geometry.verts),
                "max_vert": max(geometry.verts),
                "matrix": tuple(shape.transformation.matrix.data),
                "openings": sorted(
                    [o.RelatedOpeningElement.GlobalId for o in getattr(element, "HasOpenings", []) or []]
                ),
                "projections": sorted(
                    [o.RelatedFeatureElement.GlobalId for o in getattr(element, "HasProjections", []) or []]
                ),
            }
            if not iterator.next():
                break
        return shapes

    def get_settings(self, ifc):
        settings = ifcopenshell.geom.settings()
        settings.set(settings.STRICT_TOLERANCE, True)
        # Are you feeling lucky?
        settings.set(settings.DISABLE_BOOLEAN_RESULT, True)
        # Are you feeling very lucky?
        settings.set(settings.DISABLE_OPENING_SUBTRACTIONS, True)
        # 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 ifc.by_type("IfcGeometricRepresentationSubContext")
            if c.ContextIdentifier in ["Body", "Facetation"]
        ]
        # Ideally, all representations should be in a subcontext, but some BIM programs don't do this correctly
        body_contexts.extend(
            [
                c.id()
                for c in ifc.by_type("IfcGeometricRepresentationContext", include_subtypes=False)
                if c.ContextType == "Model"
            ]
        )
        if body_contexts:
            settings.set_context_ids(body_contexts)
        return settings

    def export(self, path):
        with open(path, "w", encoding="utf-8") as diff_file:
            json.dump(
                {
                    "added": list(self.added_elements),
                    "deleted": list(self.deleted_elements),
                    "changed": self.change_register,
                },
                diff_file,
                indent=4,
            )

    def get_precision(self):
        contexts = [c for c in self.new.by_type("IfcGeometricRepresentationContext") if c.ContextType == "Model"]
        if contexts:
            return contexts[0].Precision or 1e-4
        return 1e-4

    def diff_element(self, old, new):
        diff = DeepDiff(
            [a for a in old if not isinstance(a, (ifcopenshell.entity_instance, tuple))],
            [a for a in new if not isinstance(a, (ifcopenshell.entity_instance, tuple))],
            math_epsilon=self.precision,
            ignore_string_type_changes=True,
            ignore_numeric_type_changes=True,
        )
        if diff and new.GlobalId:
            self.change_register.setdefault(new.GlobalId, {}).update({"attributes_changed": True})
            return True

    def diff_element_relationships(self, old, new):
        if not self.relationships:
            return
        for relationship in self.relationships:
            if relationship == "type":
                if ifcopenshell.util.element.get_type(old) != ifcopenshell.util.element.get_type(new):
                    self.change_register.setdefault(new.GlobalId, {}).update({"type_changed": True})
                    return True
            elif relationship == "property":
                old_psets = ifcopenshell.util.element.get_psets(old)
                new_psets = ifcopenshell.util.element.get_psets(new)
                try:
                    diff = DeepDiff(
                        old_psets,
                        new_psets,
                        math_epsilon=self.precision,
                        ignore_string_type_changes=True,
                        ignore_numeric_type_changes=True,
                        exclude_regex_paths=[r".*id$"],
                    )
                except:
                    diff = True
                if diff and new.GlobalId:
                    self.change_register.setdefault(new.GlobalId, {}).update({"properties_changed": diff})
                    return True
            elif relationship == "container":
                if ifcopenshell.util.element.get_container(old) != ifcopenshell.util.element.get_container(new):
                    self.change_register.setdefault(new.GlobalId, {}).update({"container_changed": True})
                    return True
            elif relationship == "aggregate":
                if ifcopenshell.util.element.get_aggregate(old) != ifcopenshell.util.element.get_aggregate(new):
                    self.change_register.setdefault(new.GlobalId, {}).update({"aggregate_changed": True})
                    return True
            elif relationship == "classification":
                old_id = "ItemReference" if self.old.schema == "IFC2X3" else "Identification"
                new_id = "ItemReference" if self.new.schema == "IFC2X3" else "Identification"
                old_refs = [getattr(r, old_id) for r in ifcopenshell.util.classification.get_references(old)]
                new_refs = [getattr(r, new_id) for r in ifcopenshell.util.classification.get_references(new)]
                if old_refs != new_refs:
                    self.change_register.setdefault(new.GlobalId, {}).update({"classification_changed": True})
                    return True

    def diff_element_basic_geometry(self, old, new):
        old_placement = ifcopenshell.util.placement.get_local_placement(old.ObjectPlacement)
        new_placement = ifcopenshell.util.placement.get_local_placement(new.ObjectPlacement)
        if not np.allclose(old_placement[:, 3], new_placement[:, 3], atol=self.precision):
            return True
        if not np.allclose(old_placement[0:3, 0:3], new_placement[0:3, 0:3], atol=1e-2):
            return True
        old_openings = sorted([o.RelatedOpeningElement.GlobalId for o in getattr(old, "HasOpenings", []) or []])
        new_openings = sorted([o.RelatedOpeningElement.GlobalId for o in getattr(new, "HasOpenings", []) or []])
        if old_openings != new_openings:
            return True
        old_projections = sorted([o.RelatedFeatureElement.GlobalId for o in getattr(old, "HasProjections", []) or []])
        new_projections = sorted([o.RelatedFeatureElement.GlobalId for o in getattr(new, "HasProjections", []) or []])
        if old_projections != new_projections:
            return True
        # Option 3: check completely using Python with get_info_2 (extremely slow, not worth it)
        # old_rep_id = self.get_representation_id(old)
        # new_rep_id = self.get_representation_id(new)
        # rep_result = self.representation_ids.get(new_rep_id, None)
        # if rep_result is not None:
        #    return rep_result
        # if type(old_rep_id) != type(new_rep_id):
        #    self.representation_ids[new_rep_id] = True
        #    return True
        # if new_rep_id is None:
        #    return
        # result = self.diff_representation(old_rep_id, new_rep_id) or False
        # self.representation_ids[new_rep_id] = result
        # return result

    def diff_representation(self, old_rep_id, new_rep_id):
        old_rep = self.old.by_id(old_rep_id)
        new_rep = self.new.by_id(new_rep_id)
        if len(old_rep.Items) != len(new_rep.Items):
            return True
        for i, old_item in enumerate(old_rep.Items):
            result = self.diff_representation_item(old_item, new_rep.Items[i])
            if result is True:
                return True

    def diff_representation_item(self, old_item, new_item):
        if old_item.is_a() != new_item.is_a():
            return True
        try:
            diff = DeepDiff(
                old_item.get_info_2(recursive=True),
                new_item.get_info_2(recursive=True),
                custom_operators=[DiffTerminator()] if self.is_shallow else [],
                math_epsilon=self.precision,
                exclude_regex_paths=[r".*id']$"],
            )
        except:
            return True
        if diff:
            return True

    def get_representation_id(self, element):
        if not element.Representation:
            return
        for representation in element.Representation.Representations:
            if not representation.is_a("IfcShapeRepresentation"):
                continue
            if (
                representation.RepresentationIdentifier == "Body"
                and representation.RepresentationType != "MappedRepresentation"
            ):
                return representation.id()
            elif representation.RepresentationIdentifier == "Body":
                return representation.Items[0].MappingSource.MappedRepresentation.id()


class DiffTerminator:
    def match(self, level) -> bool:
        return True

    def give_up_diffing(self, level, diff_instance) -> bool:
        if any(diff_instance.tree.values()):
            raise Exception("Terminated")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Show the difference between two IFC files")
    parser.add_argument("old", type=str, help="The old IFC file")
    parser.add_argument("new", type=str, help="The new IFC file")
    parser.add_argument(
        "-o", "--output", type=str, help="The JSON diff file to output. Defaults to diff.json", default="diff.json"
    )
    parser.add_argument(
        "-r",
        "--relationships",
        type=str,
        help='A list of space-separated relationships, chosen from "type", "property", "container", "aggregate", "classification"',
        default="",
    )
    args = parser.parse_args()

    print("# IFC Diff")

    start = time.time()
    print("Loading old file ...")
    old = ifcopenshell.open(args.old)
    print("Loading new file ...")
    new = ifcopenshell.open(args.new)

    print("# Loading finished in {:.2f} seconds".format(time.time() - start))
    start = time.time()

    ifc_diff = IfcDiff(old, new, args.relationships.split())
    ifc_diff.diff()

    print("# Diff finished in {:.2f} seconds".format(time.time() - start))

    ifc_diff.export(args.output)
