"""Physics-first MicroDuck ladder-climbing task.

This is a small, explicit benchmark rather than a claim that the original
reference video has been recovered.  The ladder is made from MuJoCo capsule
and box geoms, copied once per parallel environment.  The policy receives a
short terrain ray scan so the front edge and the rungs are observable through
the same collision geometry used by the solver.
"""

from __future__ import annotations

import math
from copy import deepcopy

import mujoco

from mjlab.envs import mdp as envs_mdp
from mjlab.managers import ObservationTermCfg, RewardTermCfg, TerminationTermCfg
from mjlab.sensor import GridPatternCfg, ObjRef, RayCastSensorCfg
from mjlab.tasks.velocity import mdp
from mjlab.utils.noise import UniformNoiseCfg as Unoise

from mjlab_microduck.tasks import mdp as microduck_mdp
from mjlab_microduck.tasks.microduck_velocity_env_cfg import (
    MicroduckRlCfg,
    make_microduck_velocity_env_cfg,
)


LADDER_START_X = 0.30
LADDER_END_X = 1.24
LADDER_TOP_Z = 0.20
PLATFORM_X = 1.58
PLATFORM_TOP_Z = 0.24


def _add_ladder_copy(spec: mujoco.MjSpec, env_id: int, origin: tuple[float, float, float]) -> None:
    """Add one collidable slanted ladder and its top platform."""
    root = spec.worldbody.add_body(name=f"ladder_env_{env_id}", pos=origin)
    prefix = f"ladder_{env_id}_"
    geom_kwargs = {
        "contype": 1,
        "conaffinity": 1,
        "friction": (1.05, 0.04, 0.01),
        "group": 0,
    }

    # Two rails follow the incline.  Capsules avoid sharp box corners while
    # keeping the ladder a real contact object rather than a visual overlay.
    for side, y in (("left", -0.16), ("right", 0.16)):
        root.add_geom(
            name=f"{prefix}{side}_rail",
            type=mujoco.mjtGeom.mjGEOM_CAPSULE,
            fromto=(LADDER_START_X, y, 0.035, LADDER_END_X, y, LADDER_TOP_Z),
            size=(0.017,),
            rgba=(0.72, 0.37, 0.12, 1.0),
            **geom_kwargs,
        )

    # Closely spaced rungs deliberately make this a contact-rich stepping
    # problem.  The spacing is an initial curriculum value, not a claim about
    # the exact dimensions of the reference video.
    for rung_id, t in enumerate((0.08, 0.23, 0.38, 0.53, 0.68, 0.83, 0.98)):
        x = LADDER_START_X + t * (LADDER_END_X - LADDER_START_X)
        z = 0.035 + t * (LADDER_TOP_Z - 0.035)
        root.add_geom(
            name=f"{prefix}rung_{rung_id}",
            type=mujoco.mjtGeom.mjGEOM_CAPSULE,
            fromto=(x, -0.18, z, x, 0.18, z),
            size=(0.012,),
            rgba=(0.84, 0.56, 0.20, 1.0),
            **geom_kwargs,
        )

    # A short platform gives the task a visible finish state.  The robot must
    # climb onto it; simply walking beside the ladder does not count as
    # success because success also checks the base height.
    root.add_geom(
        name=f"{prefix}top_platform",
        type=mujoco.mjtGeom.mjGEOM_BOX,
        pos=(PLATFORM_X, 0.0, PLATFORM_TOP_Z - 0.035),
        size=(0.34, 0.46, 0.035),
        rgba=(0.20, 0.46, 0.30, 1.0),
        **geom_kwargs,
    )

    # A low back stop makes the top platform a stable landing target without
    # enclosing the robot in a wall.
    root.add_geom(
        name=f"{prefix}top_stop",
        type=mujoco.mjtGeom.mjGEOM_BOX,
        pos=(PLATFORM_X + 0.30, 0.0, PLATFORM_TOP_Z + 0.08),
        size=(0.025, 0.46, 0.08),
        rgba=(0.16, 0.34, 0.24, 1.0),
        **geom_kwargs,
    )


def _add_ladder_scene(spec: mujoco.MjSpec) -> None:
    """Insert one ladder at every origin already generated by mjlab.

    Reading ``env_origin_*`` sites is intentional: ``SceneCfg`` is deep-copied
    after registration and command-line overrides can change the vectorized
    environment count/spacing.  A closure over the original config would then
    silently create the wrong number of ladders.
    """
    origins = [
        tuple(float(value) for value in site.pos)
        for site in spec.sites
        if site.name.startswith("env_origin_")
    ]
    for env_id, origin in enumerate(origins):
        _add_ladder_copy(spec, env_id, origin)


def _set_fixed_forward_command(cfg) -> None:
    command = cfg.commands["twist"]
    command.resampling_time_range = (8.0, 8.0)
    command.rel_standing_envs = 0.0
    command.rel_heading_envs = 0.0
    command.rel_turn_in_place_envs = 0.0
    command.ranges.lin_vel_x = (0.12, 0.18)
    command.ranges.lin_vel_y = (0.0, 0.0)
    command.ranges.ang_vel_z = (0.0, 0.0)
    if hasattr(command, "heading_command"):
        command.heading_command = False
        command.ranges.heading = None
    command.debug_vis = False


def make_microduck_ladder_env_cfg(play: bool = False):
    """Create the first trainable MicroDuck ladder-climbing environment."""
    cfg = make_microduck_velocity_env_cfg(play=play, rough=False)

    # A flat ground plane supplies the common contact surface.  The ladder and
    # platform are added by spec_fn at every vectorized environment origin.
    cfg.scene.terrain.terrain_type = "plane"
    cfg.scene.terrain.terrain_generator = None
    cfg.scene.env_spacing = 4.0
    cfg.scene.spec_fn = _add_ladder_scene
    cfg.scene.extent = 2.8

    cfg.episode_length_s = 12.0
    cfg.viewer.body_name = "trunk_base"
    cfg.viewer.distance = 1.8
    cfg.viewer.elevation = -12.0
    cfg.viewer.azimuth = 90.0
    cfg.viewer.max_extra_envs = 0

    # Start just before the first rung.  A small lateral/yaw perturbation is
    # retained so the policy does not only memorize one exact reset pose.
    cfg.events["reset_base"].params["pose_range"] = {
        "x": (0.0, 0.02),
        "y": (-0.04, 0.04),
        "z": (0.12, 0.13),
        "yaw": (-0.06, 0.06),
    }
    _set_fixed_forward_command(cfg)

    # Keep the first benchmark physically legible.  Robustness randomization
    # can be re-enabled after a policy has learned the nominal climb.
    cfg.events.pop("push_robot", None)
    for event_name in (
        "randomize_com",
        "randomize_head_com",
        "randomize_mass_inertia",
        "randomize_joint_friction",
        "randomize_armature",
        "randomize_motor_gains",
        "randomize_base_orientation",
        "encoder_bias",
    ):
        cfg.events.pop(event_name, None)
    cfg.events["foot_friction"].params["ranges"] = (1.0, 1.0)

    # Add a compact forward-looking raycast.  Ladder geoms use group 0, so the
    # scan observes the actual rails, rungs and platform that MuJoCo collides
    # against.  The normal MicroDuck velocity config removes its base terrain
    # scan, so this task owns an explicit one rather than trying to reuse it.
    ladder_scan = RayCastSensorCfg(
        name="ladder_scan",
        frame=ObjRef(type="body", name="trunk_base", entity="robot"),
        ray_alignment="yaw",
        pattern=GridPatternCfg(size=(1.4, 0.8), resolution=0.1),
        max_distance=1.0,
        exclude_parent_body=True,
        include_geom_groups=(0,),
        debug_vis=bool(play),
    )
    cfg.scene.sensors = tuple(cfg.scene.sensors) + (ladder_scan,)
    cfg.observations["actor"].terms["height_scan"] = ObservationTermCfg(
        func=envs_mdp.height_scan,
        params={"sensor_name": "ladder_scan"},
        noise=Unoise(n_min=-0.02, n_max=0.02),
        scale=1.0,
    )
    cfg.observations["critic"].terms["height_scan"] = ObservationTermCfg(
        func=envs_mdp.height_scan,
        params={"sensor_name": "ladder_scan"},
        scale=1.0,
    )

    # The velocity recipe already provides upright and foot-contact shaping.
    # Add potential-based route progress and height progress: holding a pose
    # pays zero, while advancing toward the platform pays for actual motion.
    cfg.rewards["track_linear_velocity"].weight = 1.0
    cfg.rewards["track_angular_velocity"].weight = 0.0
    cfg.rewards["upright"].weight = 3.0
    cfg.rewards["air_time"].weight = 0.5
    cfg.rewards["foot_clearance"].weight = -0.5
    cfg.rewards["foot_slip"].weight = -0.2
    cfg.rewards["ladder_path_progress"] = RewardTermCfg(
        func=microduck_mdp.ladder_path_progress,
        weight=10.0,
        params={
            "start_x": 0.0,
            "finish_x": PLATFORM_X + 0.18,
            "platform_z": PLATFORM_TOP_Z,
        },
    )
    cfg.rewards["ladder_height_progress"] = RewardTermCfg(
        func=microduck_mdp.ladder_height_progress,
        weight=8.0,
        params={"start_z": 0.115, "finish_z": PLATFORM_TOP_Z},
    )

    cfg.terminations["ladder_success"] = TerminationTermCfg(
        func=microduck_mdp.ladder_success,
        time_out=False,
        params={
            "finish_x": PLATFORM_X + 0.12,
            "platform_z": PLATFORM_TOP_Z - 0.01,
            "max_tilt_deg": 35.0,
        },
    )

    # The base template's command/DR curricula refer to events removed above
    # and terrain levels that do not exist on a fixed ladder.  V1 keeps the
    # curriculum explicit and stable; robustness stages can add these terms
    # back once nominal climbing works.
    cfg.curriculum = {}

    return cfg


MicroduckLadderRlCfg = deepcopy(MicroduckRlCfg)
MicroduckLadderRlCfg.experiment_name = "ladder_climb"
MicroduckLadderRlCfg.run_name = "ladder-v1"
MicroduckLadderRlCfg.save_interval = 100
MicroduckLadderRlCfg.max_iterations = 4_000
