import os
import subprocess as sp
import sys
import time
from collections import namedtuple
from itertools import groupby

import numpy as np
import pybullet as p
from pyquaternion import Quaternion

from Setting import *
from dataset.preprocess.math_utils import Transform

sys.path.append('/')

# handle to a simulated rigid body
Body = namedtuple('Body', ['id', 'bid', 'vid', 'cid', 'static'])

# a body-body contact record
Contact = namedtuple(
    'Contact', ['flags', 'idA', 'idB', 'linkIndexA', 'linkIndexB',
                'positionOnAInWS', 'positionOnBInWS',
                'contactNormalOnBInWS', 'distance', 'normalForce']
)

# ray intersection record
Intersection = namedtuple('Intersection', ['id', 'linkIndex', 'ray_fraction', 'position', 'normal'])


class Simulator:
    def __init__(self, mode='direct', bullet_server_binary=None, data_dir_base=None, verbose=False):
        self._mode = mode
        self._verbose = verbose
        module_dir = os.path.dirname(os.path.abspath(__file__))
        if data_dir_base:
            self._data_dir_base = data_dir_base
        else:
            self._data_dir_base = f"{dataRoot}/"
        if bullet_server_binary:
            self._bullet_server_binary = bullet_server_binary
        else:
            self._bullet_server_binary = os.path.join(module_dir, '../../..', 'bullet_shared_memory_server')
        self._obj_id_to_body = {}
        self._bid_to_body = {}
        self._pid = None
        self._bullet_server = None
        self.connect()

    def connect(self):
        # disconnect and kill existing servers
        if self._pid:
            p.disconnect(physicsClientId=self._pid)
            self._pid = None
        if self._bullet_server:
            print(f'Restarting by killing bullet server pid={self._bullet_server.pid}...')
            self._bullet_server.kill()
            time.sleep(1)  # seems necessary to prevent deadlock on re-connection attempt
            self._bullet_server = None

        # reconnect to appropriate server type
        if self._mode == 'gui':
            self._pid = p.connect(p.GUI)
        elif self._mode == 'direct':
            self._pid = p.connect(p.DIRECT)
        elif self._mode == 'shared_memory':
            print(f'Restarting bullet server process...')
            self._bullet_server = sp.Popen([self._bullet_server_binary])
            time.sleep(1)  # seems necessary to prevent deadlock on connection attempt
            self._pid = p.connect(p.SHARED_MEMORY)
        else:
            raise RuntimeError(f'Unknown simulator server mode={self._mode}')

        # reset and initialize gui if needed
        p.resetSimulation(physicsClientId=self._pid)
        if self._mode == 'gui':
            p.configureDebugVisualizer(p.COV_ENABLE_Y_AXIS_UP, 1, physicsClientId=self._pid)
            p.configureDebugVisualizer(p.COV_ENABLE_RGB_BUFFER_PREVIEW, 0, physicsClientId=self._pid)
            p.configureDebugVisualizer(p.COV_ENABLE_DEPTH_BUFFER_PREVIEW, 0, physicsClientId=self._pid)
            p.configureDebugVisualizer(p.COV_ENABLE_SEGMENTATION_MARK_PREVIEW, 0, physicsClientId=self._pid)
            # disable rendering during loading -> much faster
            p.configureDebugVisualizer(p.COV_ENABLE_RENDERING, 0, physicsClientId=self._pid)

    def __del__(self):
        if self._bullet_server:
            print(f'Process terminating. Killing bullet server pid={self._bullet_server.pid}...')
            self._bullet_server.kill()

    def run(self):
        if self._mode == 'gui':
            # infinite ground plane and gravity
            # plane_cid = p.createCollisionShape(p.GEOM_PLANE, planeNormal=[0, 1, 0])
            # plane_bid = p.createMultiBody(baseMass=0, baseCollisionShapeIndex=plane_cid)
            p.setGravity(0, -10, 0, physicsClientId=self._pid)
            p.setRealTimeSimulation(1, physicsClientId=self._pid)
            self.set_gui_rendering(enabled=True)
            while True:
                contacts = self.get_contacts(include_collision_with_static=True)
                if self._verbose:
                    print(f'#contacts={len(contacts)}, contact_pairs={contacts.keys()}')
        else:
            self.step()
            contacts = self.get_contacts(include_collision_with_static=True)
            if self._verbose:
                for (k, c) in contacts.items():
                    cp = self.get_closest_point(obj_id_a=c.idA, obj_id_b=c.idB)
                    print(f'contact pair={k} record={cp}')
                print(f'#contacts={len(contacts)}, contact_pairs={contacts.keys()}')

    def set_gui_rendering(self, enabled):
        if not self._mode == 'gui':
            return False
        center = np.array([0.0, 0.0, 0.0])
        num_obj = 0
        for obj_id in self._obj_id_to_body.keys():
            pos, _ = self.get_state(obj_id)
            if not np.allclose(pos, [0, 0, 0]):  # try to ignore room object "identity" transform
                num_obj += 1
                center += pos
        center /= num_obj
        p.resetDebugVisualizerCamera(cameraDistance=10.0,
                                     cameraYaw=45.0,
                                     cameraPitch=-30.0,
                                     cameraTargetPosition=center,
                                     physicsClientId=self._pid)
        p.configureDebugVisualizer(p.COV_ENABLE_RENDERING, 1 if enabled else 0, physicsClientId=self._pid)
        return enabled

    def add_mesh(self, obj_id, obj_file, transform, vis_mesh_file=None, static=False):

        if static:
            cid = p.createCollisionShape(p.GEOM_MESH, fileName=obj_file, meshScale=transform.scale, flags=p.GEOM_FORCE_CONCAVE_TRIMESH, physicsClientId=self._pid)
        else:
            cid = p.createCollisionShape(p.GEOM_MESH, fileName=obj_file, meshScale=transform.scale,
                                         physicsClientId=self._pid)
        vid = -1
        if vis_mesh_file:
            vid = p.createVisualShape(p.GEOM_MESH, fileName=vis_mesh_file, meshScale=transform.scale,
                                      physicsClientId=self._pid)
        rot_q = np.roll(transform.rotation.elements, -1)  # w,x,y,z -> x,y,z,w (which pybullet expects)
        mass = 0 if static else 1
        bid = p.createMultiBody(baseMass=mass,
                                baseCollisionShapeIndex=cid,
                                baseVisualShapeIndex=vid,
                                basePosition=transform.translation,
                                baseOrientation=rot_q,
                                physicsClientId=self._pid)
        body = Body(id=obj_id, bid=bid, vid=vid, cid=cid, static=static)
        self._obj_id_to_body[obj_id] = body
        self._bid_to_body[bid] = body
        return body

    def add_box(self, obj_id, half_extents, transform, static=False):
        cid = p.createCollisionShape(p.GEOM_BOX, halfExtents=half_extents, physicsClientId=self._pid)
        rot_q = np.roll(transform.rotation.elements, -1)  # w,x,y,z -> x,y,z,w (which pybullet expects)
        mass = 0 if static else 1
        bid = p.createMultiBody(baseMass=mass,
                                baseCollisionShapeIndex=cid,
                                basePosition=transform.translation,
                                baseOrientation=rot_q,
                                physicsClientId=self._pid)
        body = Body(id=obj_id, bid=bid, vid=-1, cid=cid, static=static)
        self._obj_id_to_body[obj_id] = body
        self._bid_to_body[bid] = body
        return body

    # House-specific functions
    def add_object(self, node, create_vis_mesh=False, static=False):
        model_id = node.modelId.replace('_mirror', '')  # TODO: need to otherwise account for mirror?
        object_dir = os.path.join(self._data_dir_base, 'object')
        basename = f'{object_dir}/{model_id}/{model_id}'
        vis_obj_filename = f'{basename}.obj' if create_vis_mesh else None
        col_obj_filename = f'{basename}.vhacd.obj'
        # print(vis_obj_filename)
        if not os.path.exists(col_obj_filename):
            # print('WARNING: collision mesh {col_obj_filename} unavailable, using visual mesh instead.')
            col_obj_filename = f'{basename}.obj'
        return self.add_mesh(obj_id=node.id, obj_file=col_obj_filename, transform=Transform.from_node(node),
                             vis_mesh_file=vis_obj_filename, static=static)

    def add_wall(self, node):
        h = node['height']
        p0 = np.transpose(np.matrix(node['points'][0]))
        p1 = np.transpose(np.matrix(node['points'][1]))
        c = (p0 + p1) * 0.5
        c[1] = h * 0.5
        dp = p1 - p0
        dp_l = np.linalg.norm(dp)
        dp = dp / dp_l
        angle = np.arccos(dp[0])
        rot_q = Quaternion(axis=[0, 1, 0], radians=angle)
        half_extents = np.array([dp_l, h, node['depth']]) * 0.5
        return self.add_box(obj_id=node['id'], half_extents=half_extents,
                            transform=Transform(translation=c, rotation=rot_q), static=True)

    def add_room(self, node, wall=True, floor=True, ceiling=False):
        def add_architecture(n, obj_file, suffix):
            # print('------------------------------')
            # print(obj_file)
            # print('------------------------------')
            return self.add_mesh(obj_id=n.id + suffix, obj_file=obj_file, transform=Transform(), vis_mesh_file=None,
                                 static=True)
        room_id = node.modelId
        room_dir = os.path.join(self._data_dir_base, 'room')
        basename = f'{room_dir}/{node.house_id}/{room_id}'
        body_ids = []
        if wall:
            # print('---------------------------')
            # print(f'{basename}w.obj')
            # print('---------------------------')
            body_wall = add_architecture(node, f'{basename}w.obj', '')  # treat walls as room (=room.id, no suffix)
            body_ids.append(body_wall)
        if floor:
            body_floor = add_architecture(node, f'{basename}f.obj', 'f')
            body_ids.append(body_floor)
        if ceiling:
            body_ceiling = add_architecture(node, f'{basename}c.obj', 'c')
            body_ids.append(body_ceiling)
        return body_ids

    def add_house(self, house, no_walls=False, no_ceil=False, no_floor=False, use_separate_walls=False, static=False):
        for node in house.nodes:
            if not node.valid:
                continue
            if not hasattr(node, 'body'):
                node.body = None
            if node.type == 'Object':
                node.body = self.add_object(node,static=static)
            if node.type == 'Room':
                ceil = False if no_ceil else not (hasattr(node, 'hideCeiling') and node.hideCeiling == 1)
                wall = False if (no_walls or use_separate_walls) else not (hasattr(node, 'hideWalls') and node.hideWalls == 1)
                floor = False if no_floor else not (hasattr(node, 'hideFloor') and node.hideFloor == 1)
                node.body = self.add_room(node, wall=wall, floor=floor, ceiling=ceil)
            if node.type == 'Box':
                half_widths = list(map(lambda x: 0.5 * x, node.dimensions))
                node.body = self.add_box(obj_id=node.id, half_extents=half_widths, transform=Transform.from_node(node),
                                         static=static)
        if use_separate_walls and not no_walls:
            for wall in house.walls:
                wall['body'] = self.add_wall(wall)

    def add_house_room_only(self, house, room, no_walls=False, no_ceil=True, no_floor=False, use_separate_walls=False,
                            only_architecture=False, static=False):
        # walls, ceil, floor logic not fleshed out due to current limited use case
        room_node = [node for node in house.nodes if node.id == room.id]
        if len(room_node) < 1:
            raise Exception("Missing Room")
        if only_architecture:
            house.nodes = room_node
        else:
            house.nodes = [node for node in room.nodes]
            house.nodes.append(room_node[0])

        for node in house.nodes:
            if not node.valid:
                continue
            if not hasattr(node, 'body'):
                node.body = None
            if node.type == 'Object':
                node.body = self.add_object(node, static=static)
            if node.type == 'Room':
                ceil = False if no_ceil else not (hasattr(node, 'hideCeiling') and node.hideCeiling == 1)
                wall = False if (no_walls or use_separate_walls) else not (hasattr(node, 'hideWalls') and node.hideWalls == 1)
                floor = False if no_floor else not (hasattr(node, 'hideFloor') and node.hideFloor == 1)
                node.body = self.add_room(node, wall=wall, floor=floor, ceiling=ceil)
            if node.type == 'Box':
                half_widths = list(map(lambda x: 0.5 * x, node.dimensions))
                node.body = self.add_box(obj_id=node.id, half_extents=half_widths, transform=Transform.from_node(node),
                                         static=static)
        if use_separate_walls and not no_walls:
            for wall in house.walls:
                wall['body'] = self.add_wall(wall)

    def remove(self, obj_id):
        body = self._obj_id_to_body[obj_id]
        p.removeBody(bodyUniqueId=body.bid, physicsClientId=self._pid)
        del self._obj_id_to_body[obj_id]
        del self._bid_to_body[body.bid]

    def set_state(self, obj_id, position, rotation_q):
        body = self._obj_id_to_body[obj_id]
        rot_q = np.roll(rotation_q.elements, -1)  # w,x,y,z -> x,y,z,w (which pybullet expects)
        p.resetBasePositionAndOrientation(bodyUniqueId=body.bid, posObj=position, ornObj=rot_q,
                                          physicsClientId=self._pid)

    def get_state(self, obj_id):
        body = self._obj_id_to_body[obj_id]
        pos, q = p.getBasePositionAndOrientation(bodyUniqueId=body.bid, physicsClientId=self._pid)
        rotation = Quaternion(x=q[0], y=q[1], z=q[2], w=q[3])
        return pos, rotation

    def step(self):
        p.stepSimulation(physicsClientId=self._pid)

    def reset(self):
        p.resetSimulation(physicsClientId=self._pid)
        self._obj_id_to_body = {}
        self._bid_to_body = {}

    def get_closest_point(self, obj_id_a, obj_id_b, max_distance=np.inf):
        """
        Return record with distance between closest points between pair of nodes if within max_distance or None.
        """
        bid_a = self._obj_id_to_body[obj_id_a].bid
        bid_b = self._obj_id_to_body[obj_id_b].bid
        cps = p.getClosestPoints(bodyA=bid_a, bodyB=bid_b, distance=max_distance, physicsClientId=self._pid)
        cp = None
        if len(cps) > 0:
            closest_points = self._convert_contacts(cps)
            cp = min(closest_points, key=lambda x: x.distance)
        del cps  # NOTE force garbage collection of pybullet objects
        return cp

    def get_contacts(self, obj_id_a=None, obj_id_b=None, only_closest_contact_per_pair=True,
                     include_collision_with_static=True):
        """
        Return all current contacts. When include_collision_with_statics is true, include contacts with static bodies
        """
        bid_a = self._obj_id_to_body[obj_id_a].bid if obj_id_a else -1
        bid_b = self._obj_id_to_body[obj_id_b].bid if obj_id_b else -1
        cs = p.getContactPoints(bodyA=bid_a, bodyB=bid_b, physicsClientId=self._pid)
        contacts = self._convert_contacts(cs)
        del cs  # NOTE force garbage collection of pybullet objects

        if not include_collision_with_static:
            def not_contact_with_static(c):
                static_a = self._obj_id_to_body[c.idA].static
                static_b = self._obj_id_to_body[c.idB].static
                return not static_a and not static_b
            contacts = filter(not_contact_with_static, contacts)
            # print(f'#all_contacts={len(all_contacts)} to #non_static_contacts={len(non_static_contacts)}')

        if only_closest_contact_per_pair:
            def bid_pair_key(x):
                return str(x.idA) + '_' + str(x.idB)
            contacts = sorted(contacts, key=bid_pair_key)
            min_dist_contact_by_pair = {}
            for k, g in groupby(contacts, key=bid_pair_key):
                min_dist_contact = min(g, key=lambda x: x.distance)
                min_dist_contact_by_pair[k] = min_dist_contact
            contacts = min_dist_contact_by_pair.values()

        # convert into dictionary of form (id_a, id_b) -> Contact
        contacts_dict = {}
        for c in contacts:
            key = (c.idA, c.idB)
            contacts_dict[key] = c

        return contacts_dict

    def _convert_contacts(self, contacts):
        out = []
        for c in contacts:
            bid_a = c[1]
            bid_b = c[2]
            if bid_a not in self._bid_to_body or bid_b not in self._bid_to_body:
                continue
            id_a = self._bid_to_body[bid_a].id
            id_b = self._bid_to_body[bid_b].id
            o = Contact(flags=c[0], idA=id_a, idB=id_b, linkIndexA=c[3], linkIndexB=c[4],
                        positionOnAInWS=c[5], positionOnBInWS=c[6], contactNormalOnBInWS=c[7],
                        distance=c[8], normalForce=c[9])
            out.append(o)
        return out

    def ray_test(self, from_pos, to_pos):
        hit = p.rayTest(rayFromPosition=from_pos, rayToPosition=to_pos, physicsClientId=self._pid)
        intersection = Intersection._make(*hit)
        del hit  # NOTE force garbage collection of pybullet objects
        if intersection.id >= 0:  # if intersection, replace bid with id
            intersection = intersection._replace(id=self._bid_to_body[intersection.id].id)
        return intersection


if __name__ == '__main__':
    from dataset.preprocess.House import House
    sim = Simulator(verbose=True)
    h = House(id_='0f7759ed-ccf3-4115-bd00-cf4d8165e6d3', include_arch_information=False)
    # h = House(index=5)
    sim.add_house(h, no_walls=False, no_ceil=True, use_separate_walls=False, static=False)
    sim.run()
