# Copyright (c) 2023 NVIDIA CORPORATION.  All rights reserved.
# NVIDIA CORPORATION and its licensors retain all intellectual property
# and proprietary rights in and to this software, related documentation
# and any modifications thereto.  Any use, reproduction, disclosure or
# distribution of this software and related documentation without an express
# license agreement from NVIDIA CORPORATION is strictly prohibited.

"""Node simulating particles."""

from math import inf
import traceback

import numpy as np
import omni.graph.core as og
import omni.timeline
import warp as wp

import omni.warp.nodes
from omni.warp.nodes.ogn.OgnParticlesSimulateDatabase import OgnParticlesSimulateDatabase


USE_GRAPH = True

PROFILING = False


#   Kernels
# ------------------------------------------------------------------------------


@wp.kernel(enable_backward=False)
def query_max_value_kernel(
    values: wp.array(dtype=float),
    out_max: wp.array(dtype=float),
):
    wp.atomic_max(out_max, 0, values[wp.tid()])


@wp.kernel(enable_backward=False)
def compute_particles_inv_mass_kernel(
    masses: wp.array(dtype=float),
    out_inv_masses: wp.array(dtype=float),
):
    tid = wp.tid()
    out_inv_masses[tid] = 1.0 / masses[tid]


@wp.kernel(enable_backward=False)
def compute_particles_radius_kernel(
    widths: wp.array(dtype=float),
    out_radii: wp.array(dtype=float),
):
    tid = wp.tid()
    out_radii[tid] = widths[tid] * 0.5


@wp.kernel(enable_backward=False)
def transform_points_kernel(
    points: wp.array(dtype=wp.vec3),
    xform: wp.mat44,
    out_points: wp.array(dtype=wp.vec3),
):
    tid = wp.tid()
    out_points[tid] = wp.transform_point(xform, points[tid])


@wp.kernel(enable_backward=False)
def update_collider_kernel(
    points_0: wp.array(dtype=wp.vec3),
    points_1: wp.array(dtype=wp.vec3),
    xform_0: wp.mat44,
    xform_1: wp.mat44,
    sim_dt: float,
    out_points: wp.array(dtype=wp.vec3),
    out_velocities: wp.array(dtype=wp.vec3),
):
    tid = wp.tid()

    point_0 = wp.transform_point(xform_0, points_0[tid])
    point_1 = wp.transform_point(xform_1, points_1[tid])

    out_points[tid] = point_0
    out_velocities[tid] = (point_1 - point_0) / sim_dt


@wp.kernel(enable_backward=False)
def update_particles_kernel(
    points_0: wp.array(dtype=wp.vec3),
    xform: wp.mat44,
    out_points: wp.array(dtype=wp.vec3),
    out_velocities: wp.array(dtype=wp.vec3),
):
    tid = wp.tid()

    point = wp.transform_point(xform, points_0[tid])
    diff = point - points_0[tid]

    out_points[tid] = point
    out_velocities[tid] = out_velocities[tid] + diff


#   Internal State
# ------------------------------------------------------------------------------


class InternalState:
    """Internal state for the node."""

    def __init__(self) -> None:
        self.sim_dt = None
        self.sim_tick = None
        self.model = None
        self.integrator = None
        self.state_0 = None
        self.state_1 = None
        self.xform = None
        self.collider_xform = None
        self.collider_mesh = None
        self.collider_points_0 = None
        self.collider_points_1 = None
        self.graph = None

        self.sim_enabled = True
        self.time = 0.0

        self.is_valid = False

        self.attr_tracking = omni.warp.nodes.AttrTracking(
            (
                "substepCount",
                "gravity",
                "globalScale",
                "contactElasticStiffness",
                "contactFrictionStiffness",
                "contactFrictionCoeff",
                "contactDampingStiffness",
                "particlesQueryRange",
                "particlesContactAdhesion",
                "particlesContactCohesion",
                "colliderContactDistance",
                "colliderContactQueryRange",
                "groundEnabled",
                "groundAltitude",
            ),
        )

    def needs_initialization(self, db: OgnParticlesSimulateDatabase) -> bool:
        """Checks if the internal state needs to be (re)initialized."""
        if not self.is_valid or not db.inputs.enabled or not self.sim_enabled:
            return True

        if self.attr_tracking.have_attrs_changed(db):
            return True

        if db.inputs.time < self.time:
            # Reset the simulation when we're rewinding.
            return True

        return False

    def initialize(
        self,
        db: OgnParticlesSimulateDatabase,
        device: wp.context.Device,
    ) -> bool:
        """Initializes the internal state."""
        # Lazy load warp.sim here to not slow down extension loading.
        import warp.sim

        # Compute the simulation time step.
        timeline = omni.timeline.get_timeline_interface()
        sim_rate = timeline.get_ticks_per_second()
        sim_dt = 1.0 / sim_rate

        # Initialize Warp's simulation model builder.
        builder = wp.sim.ModelBuilder()

        # Retrieve some data from the particles points.
        points = omni.warp.nodes.points_get_points(db.inputs.particles)
        xform = omni.warp.nodes.bundle_get_world_xform(db.inputs.particles)

        # Transform the particles point positions into world space.
        world_points = wp.empty(len(points), dtype=wp.vec3)
        wp.launch(
            kernel=transform_points_kernel,
            dim=len(points),
            inputs=[
                points,
                xform.T,
            ],
            outputs=[
                world_points,
            ],
        )

        if db.inputs.collider.valid:
            # Retrieve some data from the collider mesh.
            collider_points = omni.warp.nodes.mesh_get_points(db.inputs.collider)
            collider_xform = omni.warp.nodes.bundle_get_world_xform(db.inputs.collider)

            # Transform the collider point positions into world space.
            collider_world_points = wp.empty(
                len(collider_points),
                dtype=wp.vec3,
            )
            wp.launch(
                kernel=transform_points_kernel,
                dim=len(collider_points),
                inputs=[
                    collider_points,
                    collider_xform.T,
                ],
                outputs=[
                    collider_world_points,
                ],
            )

            # Initialize Warp's mesh instance, which requires
            # triangulated meshes.
            collider_face_vertex_indices = omni.warp.nodes.mesh_triangulate(
                db.inputs.collider,
            )
            collider_mesh = wp.sim.Mesh(
                collider_world_points.numpy(),
                collider_face_vertex_indices.numpy(),
                compute_inertia=False,
            )

            # Register the collider geometry mesh into Warp's simulation model
            # builder.
            builder.add_shape_mesh(
                body=-1,
                mesh=collider_mesh,
                pos=(0.0, 0.0, 0.0),
                rot=(0.0, 0.0, 0.0, 1.0),
                scale=(1.0, 1.0, 1.0),
            )

            # Store the collider's point positions as internal state.
            collider_points_0 = wp.empty_like(collider_points)
            collider_points_1 = wp.empty_like(collider_points)
            wp.copy(collider_points_0, collider_points)
            wp.copy(collider_points_1, collider_points)

            # Store the class members.
            self.collider_xform = collider_xform.copy()
            self.collider_mesh = collider_mesh
            self.collider_points_0 = collider_points_0
            self.collider_points_1 = collider_points_1
        else:
            self.collider_mesh = None

        # Register the ground.
        builder.set_ground_plane(
            offset=-db.inputs.groundAltitude,
            ke=db.inputs.contactElasticStiffness * db.inputs.globalScale,
            kd=db.inputs.contactDampingStiffness * db.inputs.globalScale,
            kf=db.inputs.contactFrictionStiffness * db.inputs.globalScale,
            mu=db.inputs.contactFrictionCoeff,
        )

        # Build the simulation model.
        model = builder.finalize()

        # Register the input particles into the system.
        model.particle_count = omni.warp.nodes.points_get_point_count(db.inputs.particles)

        model.particle_q = world_points
        model.particle_qd = omni.warp.nodes.points_get_velocities(db.inputs.particles)
        model.particle_mass = omni.warp.nodes.points_get_masses(db.inputs.particles)

        model.particle_inv_mass = wp.empty_like(model.particle_mass)
        wp.launch(
            compute_particles_inv_mass_kernel,
            dim=model.particle_count,
            inputs=[model.particle_mass],
            outputs=[model.particle_inv_mass],
        )

        widths = omni.warp.nodes.points_get_widths(db.inputs.particles)
        model._particle_radius = wp.empty_like(widths)
        wp.launch(
            compute_particles_radius_kernel,
            dim=model.particle_count,
            inputs=[widths],
            outputs=[model._particle_radius],
        )

        model.particle_flags = wp.empty(model.particle_count, dtype=wp.uint32)
        model.particle_flags.fill_(warp.sim.model.PARTICLE_FLAG_ACTIVE.value)

        max_width = wp.array((-inf,), dtype=float)
        wp.launch(
            query_max_value_kernel,
            dim=model.particle_count,
            inputs=[widths],
            outputs=[max_width],
        )
        model.particle_max_radius = float(max_width.numpy()[0]) * 0.5

        # Allocate a single contact per particle.
        model.allocate_soft_contacts(model.particle_count)

        # Initialize the integrator.
        integrator = wp.sim.SemiImplicitIntegrator()

        # Set the model properties.
        model.ground = db.inputs.groundEnabled
        model.gravity = db.inputs.gravity
        model.particle_adhesion = db.inputs.particlesContactAdhesion
        model.particle_cohesion = db.inputs.particlesContactCohesion
        model.particle_ke = db.inputs.contactElasticStiffness * db.inputs.globalScale
        model.particle_kf = db.inputs.contactFrictionStiffness * db.inputs.globalScale
        model.particle_mu = db.inputs.contactFrictionCoeff
        model.particle_kd = db.inputs.contactDampingStiffness * db.inputs.globalScale
        model.soft_contact_ke = db.inputs.contactElasticStiffness * db.inputs.globalScale
        model.soft_contact_kf = db.inputs.contactFrictionStiffness * db.inputs.globalScale
        model.soft_contact_mu = db.inputs.contactFrictionCoeff
        model.soft_contact_kd = db.inputs.contactDampingStiffness * db.inputs.globalScale
        model.soft_contact_margin = db.inputs.colliderContactDistance * db.inputs.colliderContactQueryRange

        # Store the class members.
        self.sim_dt = sim_dt
        self.sim_tick = 0
        self.model = model
        self.integrator = integrator
        self.state_0 = model.state()
        self.state_1 = model.state()
        self.xform = xform.copy()

        if USE_GRAPH:
            # Create the CUDA graph. We first manually load the necessary
            # modules to avoid the capture to load all the modules that are
            # registered and possibly not relevant.
            wp.load_module(device=device)
            wp.load_module(module=warp.sim, device=device, recursive=True)
            wp.capture_begin(force_module_load=False)
            step(db)
            self.graph = wp.capture_end()
        else:
            self.graph = None

        self.attr_tracking.update_state(db)

        return True


#   Compute
# ------------------------------------------------------------------------------


def update_collider(
    db: OgnParticlesSimulateDatabase,
) -> None:
    """Updates the collider state."""
    state = db.internal_state

    points = omni.warp.nodes.mesh_get_points(db.inputs.collider)
    xform = omni.warp.nodes.bundle_get_world_xform(db.inputs.collider)

    # Swap the previous and current collider point positions.
    (state.collider_points_0, state.collider_points_1) = (
        state.collider_points_1,
        state.collider_points_0,
    )

    # Store the current point positions.
    wp.copy(state.collider_points_1, points)

    # Retrieve the previous and current world transformations.
    xform_0 = state.collider_xform
    xform_1 = xform

    # Update the internal point positions and velocities.
    wp.launch(
        kernel=update_collider_kernel,
        dim=len(state.collider_mesh.vertices),
        inputs=[
            state.collider_points_0,
            state.collider_points_1,
            xform_0.T,
            xform_1.T,
            state.sim_dt,
        ],
        outputs=[
            state.collider_mesh.mesh.points,
            state.collider_mesh.mesh.velocities,
        ],
    )

    # Refit the BVH.
    state.collider_mesh.mesh.refit()

    # Update the state members.
    state.collider_xform = xform.copy()


def update_particles(
    db: OgnParticlesSimulateDatabase,
) -> None:
    """Updates the particles state."""
    state = db.internal_state

    xform = omni.warp.nodes.bundle_get_world_xform(db.inputs.particles)

    # Retrieve the previous and current world transformations.
    xform_0 = state.xform
    xform_1 = xform

    # Update the internal point positions and velocities.
    wp.launch(
        kernel=update_particles_kernel,
        dim=len(state.state_0.particle_q),
        inputs=[
            state.state_0.particle_q,
            np.matmul(np.linalg.inv(xform_0), xform_1).T,
        ],
        outputs=[
            state.state_0.particle_q,
            state.state_0.particle_qd,
        ],
    )

    # Update the state members.
    state.xform = xform.copy()


def step(db: OgnParticlesSimulateDatabase) -> None:
    """Steps through the simulation."""
    state = db.internal_state

    sim_dt = state.sim_dt / db.inputs.substepCount

    # Run the collision detection once per frame.
    wp.sim.collide(state.model, state.state_0)

    for _ in range(db.inputs.substepCount):
        state.state_0.clear_forces()
        state.integrator.simulate(
            state.model,
            state.state_0,
            state.state_1,
            sim_dt,
        )

        # Swap the previous and current states.
        (state.state_0, state.state_1) = (state.state_1, state.state_0)


def simulate(db: OgnParticlesSimulateDatabase) -> None:
    """Simulates the particles at the current time."""
    state = db.internal_state

    state.model.particle_grid.build(
        state.state_0.particle_q,
        state.model.particle_max_radius * db.inputs.particlesQueryRange,
    )

    if USE_GRAPH:
        wp.capture_launch(state.graph)
    else:
        step(db)


def compute(db: OgnParticlesSimulateDatabase, device: wp.context.Device) -> None:
    """Evaluates the node."""
    if not db.inputs.particles.valid or not db.outputs.particles.valid:
        return

    state = db.internal_state

    if not db.inputs.enabled:
        # Pass through the data.
        db.outputs.particles = db.inputs.particles

        # Store whether the simulation was last enabled.
        state.sim_enabled = False
        return

    if state.needs_initialization(db):
        # Initialize the internal state if it hasn't been already.

        # We want to use the input particles geometry as the initial state
        # of the simulation so we copy its bundle to the output one.
        db.outputs.particles = db.inputs.particles

        if not state.initialize(db, device):
            return
    else:
        # We skip the simulation if it has just been initialized.

        if state.sim_tick == 0 and omni.warp.nodes.bundle_has_changed(db.inputs.particles):
            if not state.initialize(db, device):
                return

        if (
            db.inputs.collider.valid
            and state.collider_mesh is not None
            and omni.warp.nodes.bundle_has_changed(db.inputs.collider)
        ):
            # The collider might be animated so we need to update its state.
            update_collider(db)

        if omni.warp.nodes.bundle_have_attrs_changed(db.inputs.particles, ("worldMatrix",)):
            update_particles(db)

        with omni.warp.nodes.NodeTimer("simulate", db, active=PROFILING):
            # Run the particles simulation at the current time.
            simulate(db)

        with omni.warp.nodes.NodeTimer("transform_points_to_local_space", db, active=PROFILING):
            # Retrieve some data from the particles points.
            xform = omni.warp.nodes.bundle_get_world_xform(db.inputs.particles)

            # Transform the particles point positions back into local space
            # and store them into the bundle.
            out_points = omni.warp.nodes.points_get_points(db.outputs.particles)
            wp.launch(
                kernel=transform_points_kernel,
                dim=len(out_points),
                inputs=[
                    state.state_0.particle_q,
                    np.linalg.inv(xform).T,
                ],
                outputs=[
                    out_points,
                ],
            )

        # Increment the simulation tick.
        state.sim_tick += 1

    # Store whether the simulation was last enabled.
    state.sim_enabled = True

    # Store the current time.
    state.time = db.inputs.time


#   Node Entry Point
# ------------------------------------------------------------------------------


class OgnParticlesSimulate:
    """Node."""

    @staticmethod
    def internal_state() -> InternalState:
        return InternalState()

    @staticmethod
    def compute(db: OgnParticlesSimulateDatabase) -> None:
        device = wp.get_device("cuda:0")

        try:
            with wp.ScopedDevice(device):
                compute(db, device)
        except Exception:
            db.log_error(traceback.format_exc())
            db.internal_state.is_valid = False
            return

        db.internal_state.is_valid = True

        # Fire the execution for the downstream nodes.
        db.outputs.execOut = og.ExecutionAttributeState.ENABLED
