# BlenderBIM Add-on - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of BlenderBIM Add-on.
#
# BlenderBIM Add-on is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# BlenderBIM Add-on 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with BlenderBIM Add-on.  If not, see <http://www.gnu.org/licenses/>.

import bpy
import math
import bmesh
import mathutils.geometry
import ifcopenshell
import ifcopenshell.util.type
import ifcopenshell.util.unit
import ifcopenshell.util.element
import blenderbim.bim.handler
import blenderbim.tool as tool
import blenderbim.core.type
import blenderbim.core.geometry
from blenderbim.bim.ifc import IfcStore
from math import pi, degrees, inf
from mathutils import Vector, Matrix
from ifcopenshell.api.pset.data import Data as PsetData
from ifcopenshell.api.material.data import Data as MaterialData
from blenderbim.bim.module.geometry.helper import Helper


def element_listener(element, obj):
    blenderbim.bim.handler.subscribe_to(obj, "mode", mode_callback)


def mode_callback(obj, data):
    for obj in set(bpy.context.selected_objects + [bpy.context.active_object]):
        if (
            not obj.data
            or not isinstance(obj.data, (bpy.types.Mesh, bpy.types.Curve, bpy.types.TextCurve))
            or not obj.BIMObjectProperties.ifc_definition_id
            or not bpy.context.scene.BIMProjectProperties.is_authoring
        ):
            return
        product = IfcStore.get_file().by_id(obj.BIMObjectProperties.ifc_definition_id)
        parametric = ifcopenshell.util.element.get_psets(product).get("EPset_Parametric")
        if not parametric or parametric["Engine"] != "BlenderBIM.DumbProfile":
            return
        if obj.mode == "EDIT":
            IfcStore.edited_objs.add(obj)
            bm = bmesh.from_edit_mesh(obj.data)
            bmesh.ops.dissolve_limit(bm, angle_limit=pi / 180 * 1, verts=bm.verts, edges=bm.edges)
            bmesh.update_edit_mesh(obj.data)
            bm.free()
        else:
            material_usage = ifcopenshell.util.element.get_material(product)
            x, y = obj.dimensions[0:2]
            if not material_usage.CardinalPoint:
                new_origin = obj.matrix_world @ (Vector(obj.bound_box[0]) + (Vector((x, y, 0)) / 2))
            elif material_usage.CardinalPoint == 1:
                new_origin = obj.matrix_world @ Vector(obj.bound_box[4])
            elif material_usage.CardinalPoint == 2:
                new_origin = obj.matrix_world @ (Vector(obj.bound_box[0]) + (Vector((x, 0, 0)) / 2))
            elif material_usage.CardinalPoint == 3:
                new_origin = obj.matrix_world @ Vector(obj.bound_box[0])
            elif material_usage.CardinalPoint == 4:
                new_origin = obj.matrix_world @ (Vector(obj.bound_box[4]) + (Vector((0, y, 0)) / 2))
            elif material_usage.CardinalPoint == 5:
                new_origin = obj.matrix_world @ (Vector(obj.bound_box[0]) + (Vector((x, y, 0)) / 2))
            elif material_usage.CardinalPoint == 6:
                new_origin = obj.matrix_world @ (Vector(obj.bound_box[0]) + (Vector((0, y, 0)) / 2))
            elif material_usage.CardinalPoint == 7:
                new_origin = obj.matrix_world @ Vector(obj.bound_box[7])
            elif material_usage.CardinalPoint == 8:
                new_origin = obj.matrix_world @ (Vector(obj.bound_box[3]) + (Vector((x, 0, 0)) / 2))
            elif material_usage.CardinalPoint == 9:
                new_origin = obj.matrix_world @ Vector(obj.bound_box[3])
            if (obj.matrix_world.translation - new_origin).length < 0.001:
                return
            obj.data.transform(
                Matrix.Translation(
                    (obj.matrix_world.inverted().to_quaternion() @ (obj.matrix_world.translation - new_origin))
                )
            )
            obj.matrix_world.translation = new_origin


class DumbProfileGenerator:
    def __init__(self, relating_type):
        self.relating_type = relating_type

    def generate(self):
        self.file = IfcStore.get_file()
        unit_scale = ifcopenshell.util.unit.calculate_unit_scale(IfcStore.get_file())
        material = ifcopenshell.util.element.get_material(self.relating_type)
        if material and material.is_a("IfcMaterialProfileSet"):
            self.profile_set = material
        else:
            return

        self.collection = bpy.context.view_layer.active_layer_collection.collection
        self.collection_obj = bpy.data.objects.get(self.collection.name)
        self.length = 3
        self.rotation = 0
        self.location = Vector((0, 0, 0))
        return self.derive_from_cursor()

    def derive_from_cursor(self):
        self.location = bpy.context.scene.cursor.location
        return self.create_profile()

    def create_profile(self):
        # A cube
        verts = [
            Vector((-1, -1, -1)),
            Vector((-1, -1, 1)),
            Vector((-1, 1, -1)),
            Vector((-1, 1, 1)),
            Vector((1, -1, -1)),
            Vector((1, -1, 1)),
            Vector((1, 1, -1)),
            Vector((1, 1, 1)),
        ]
        edges = []
        faces = [
            [0, 2, 3, 1],
            [2, 3, 7, 6],
            [4, 5, 7, 6],
            [0, 1, 5, 4],
            [1, 3, 7, 5],
            [0, 2, 6, 4],
        ]

        ifc_classes = ifcopenshell.util.type.get_applicable_entities(self.relating_type.is_a(), self.file.schema)
        # Standard cases are deprecated, so let's cull them
        ifc_class = [c for c in ifc_classes if "StandardCase" not in c][0]

        mesh = bpy.data.meshes.new(name="Dumb Profile")
        mesh.from_pydata(verts, edges, faces)
        obj = bpy.data.objects.new(tool.Model.generate_occurrence_name(self.relating_type, ifc_class), mesh)
        obj.location = self.location
        if self.collection_obj and self.collection_obj.BIMObjectProperties.ifc_definition_id:
            obj.location[2] = self.collection_obj.location[2]
        self.collection.objects.link(obj)

        bpy.ops.bim.assign_class(obj=obj.name, ifc_class=ifc_class, should_add_representation=False)

        if self.relating_type.is_a() in ["IfcBeamType", "IfcMemberType"]:
            obj.rotation_euler[0] = math.pi / 2
            obj.rotation_euler[2] = math.pi / 2

        element = self.file.by_id(obj.BIMObjectProperties.ifc_definition_id)
        blenderbim.core.type.assign_type(tool.Ifc, tool.Type, element=tool.Ifc.get_entity(obj), type=self.relating_type)
        profile_set_usage = ifcopenshell.util.element.get_material(element)
        pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="EPset_Parametric")
        ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Engine": "BlenderBIM.DumbProfile"})
        MaterialData.load(self.file)
        obj.select_set(True)
        return obj


class DumbProfileRegenerator:
    def regenerate_from_profile(self, usecase_path, ifc_file, settings):
        self.file = ifc_file
        profile = settings["profile"].Profile
        if not profile:
            return
        for element in self.get_elements_using_profile(profile):
            self.change_profile(element)

    def sync_object_from_profile(self, usecase_path, ifc_file, settings):
        self.file = ifc_file
        profile = settings["profile"].Profile
        if not profile:
            return
        for element in self.get_elements_using_profile(profile):
            self.sync_object(element)

    def get_elements_using_profile(self, profile):
        results = []
        for profile_set in [
            mp.ToMaterialProfileSet[0] for mp in self.file.get_inverse(profile) if mp.is_a("IfcMaterialProfile")
        ]:
            for inverse in self.file.get_inverse(profile_set):
                if not inverse.is_a("IfcMaterialProfileSetUsage"):
                    continue
                if self.file.schema == "IFC2X3":
                    for rel in self.file.get_inverse(inverse):
                        if not rel.is_a("IfcRelAssociatesMaterial"):
                            continue
                        results.extend(rel.RelatedObjects)
                else:
                    for rel in inverse.AssociatedTo:
                        results.extend(rel.RelatedObjects)
        return results

    def regenerate_from_type(self, usecase_path, ifc_file, settings):
        new_material = ifcopenshell.util.element.get_material(settings["relating_type"])
        if not new_material or not new_material.is_a("IfcMaterialProfileSet"):
            return
        self.change_profile(settings["related_object"])

    def change_profile(self, element):
        obj = IfcStore.get_element(element.id())
        if not obj:
            return
        representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
        if representation:
            blenderbim.core.geometry.switch_representation(
                tool.Geometry,
                obj=obj,
                representation=representation,
                should_reload=True,
                enable_dynamic_voids=True,
                is_global=True,
                should_sync_changes_first=False,
            )

    def sync_object(self, element):
        obj = IfcStore.get_element(element.id())
        if not obj or obj not in IfcStore.edited_objs:
            return
        bpy.ops.bim.update_representation(obj=obj.name)


class ExtendProfile(bpy.types.Operator):
    bl_idname = "bim.extend_profile"
    bl_label = "Extend Profile"
    bl_options = {"REGISTER", "UNDO"}

    def execute(self, context):
        selected_objs = context.selected_objects
        for obj in selected_objs:
            bpy.ops.bim.dynamically_void_product(obj=obj.name)
        if len(selected_objs) == 0:
            return {"FINISHED"}
        if not context.active_object:
            return {"FINISHED"}
        if len(selected_objs) == 1:
            DumbProfileExtender(context.active_object, target_coordinate=context.scene.cursor.location).extend()
            IfcStore.edited_objs.add(context.active_object)
            return {"FINISHED"}
        if len(selected_objs) < 2:
            return {"FINISHED"}
        for obj in selected_objs:
            if obj == context.active_object:
                continue
            DumbProfileExtender(obj, context.active_object).extend()
            IfcStore.edited_objs.add(obj)
        return {"FINISHED"}


class DumbProfileExtender:
    # A profile is a prismatic extrusion along its local Z axis.
    def __init__(self, profile, target=None, target_coordinate=None):
        self.profile = profile
        self.target = target
        self.target_coordinate = target_coordinate

    # An extension of a profile is determined by casting a ray from the cardinal
    # point of either extreme along the profiles local Z axis to a target
    # object. The nearest result from this raycast will be the target extension
    # point.
    def extend(self):
        extension_data = self.get_closest_extension_point()
        if not extension_data:
            return
        if extension_data["end"] == "bottom":
            self.profile.matrix_world.translation = extension_data["contact"]
            if extension_data["direction"] == "up":
                self.shift_top_faces(-extension_data["distance"])
            elif extension_data["direction"] == "down":
                self.shift_top_faces(extension_data["distance"])
        if extension_data["end"] == "top":
            if extension_data["direction"] == "up":
                self.shift_top_faces(extension_data["distance"])
            elif extension_data["direction"] == "down":
                self.shift_top_faces(-extension_data["distance"])

    def shift_top_faces(self, z):
        vertices = []
        for f in self.get_profile_top_faces():
            vertices.extend(f.vertices)
        for v in set(vertices):
            self.profile.data.vertices[v].co.z += z

    # An top face is defined as having at least one vertex on the max
    # Z-axis, and a non-insignificant Z component of its face normal
    def get_profile_top_faces(self):
        faces = []
        max_z = max([v[2] for v in self.profile.bound_box])
        for f in self.profile.data.polygons:
            if abs(f.normal.z) < 0.1:
                continue
            for v in f.vertices:
                if self.profile.data.vertices[v].co.z == max_z:
                    faces.append(f)
                    break
        return faces

    def get_closest_extension_point(self):
        top = self.profile.matrix_world @ Vector((0, 0, self.profile.dimensions[2]))
        bottom = self.profile.matrix_world.translation
        up = self.profile.matrix_world.to_quaternion() @ Vector((0, 0, 1))
        down = self.profile.matrix_world.to_quaternion() @ Vector((0, 0, -1))

        if self.target:
            return self.get_closest_extension_point_from_target_obj(top, bottom, up, down)
        elif self.target_coordinate:
            return self.get_closest_extension_point_from_target_coordinate(top, bottom, up, down)

    def get_closest_extension_point_from_target_coordinate(self, top, bottom, up, down):
        point = mathutils.geometry.intersect_line_plane(
            bottom, top, self.target_coordinate, self.profile.matrix_world.to_quaternion() @ Vector((0, 0, 1))
        )
        if not point:
            return
        top_distance = (point - top).length
        bottom_distance = (point - bottom).length
        if top_distance < bottom_distance:
            intersection_point, signed_distance = mathutils.geometry.intersect_point_line(point, top, bottom)
            if signed_distance > 0:
                return {"contact": point, "distance": top_distance, "end": "top", "direction": "down"}
            else:
                return {"contact": point, "distance": top_distance, "end": "top", "direction": "up"}
        else:
            intersection_point, signed_distance = mathutils.geometry.intersect_point_line(point, bottom, top)
            if signed_distance > 0:
                return {"contact": point, "distance": bottom_distance, "end": "bottom", "direction": "up"}
            else:
                return {"contact": point, "distance": bottom_distance, "end": "bottom", "direction": "down"}

    def get_closest_extension_point_from_target_obj(self, top, bottom, up, down):
        t_top = self.target.matrix_world.inverted() @ top
        t_bottom = self.target.matrix_world.inverted() @ bottom
        t_up = self.target.matrix_world.inverted().to_quaternion() @ up
        t_down = self.target.matrix_world.inverted().to_quaternion() @ down

        results = []
        results.append((self.target.ray_cast(t_top, t_up, distance=50), top, "top", "up"))
        results.append((self.target.ray_cast(t_top, t_down, distance=50), top, "top", "down"))
        results.append((self.target.ray_cast(t_bottom, t_up, distance=50), bottom, "bottom", "up"))
        results.append((self.target.ray_cast(t_bottom, t_down, distance=50), bottom, "bottom", "down"))

        min_distance = float(inf)
        final = None

        for result in results:
            if result[0][0]:
                contact = self.target.matrix_world @ result[0][1]
                distance = (contact - result[1]).length
                if distance < min_distance:
                    min_distance = distance
                    final = {"contact": contact, "distance": distance, "end": result[2], "direction": result[3]}

        return final
