"""Canonical model contract for the heterogeneous teleoperation prototype.

The repository contains several historical URDF/config combinations.  This
module defines the only combination used by the new simulation:

* master: ``config/master_7dof.urdf``
* slave: ``config/real_slave_7dof.urdf``

The physical slave URDF currently ends at the final wrist body and does not
contain a calibrated tool/force-sensor frame.  For simulation only, an
operational frame named ``R_EE_SIM`` is added at a documented fixed offset.
Hardware experiments must replace that offset with the measured TCP transform.
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Sequence

import numpy as np
import pinocchio as pin


CODE_ROOT = Path(__file__).resolve().parents[1]
CONFIG_ROOT = CODE_ROOT / "config"
MASTER_URDF = CONFIG_ROOT / "master_7dof.urdf"
SLAVE_URDF = CONFIG_ROOT / "real_slave_7dof.urdf"

MASTER_JOINT_NAMES = (
    "master_shoulder_pitch_joint",
    "master_shoulder_yaw_joint",
    "master_shoulder_roll_joint",
    "master_elbow_flex_joint",
    "master_wrist_roll_joint",
    "master_wrist_yaw_joint",
    "master_wrist_pitch_joint",
)

SLAVE_JOINT_NAMES = (
    "R_SHOULDER_P",
    "R_SHOULDER_R",
    "R_SHOULDER_Y",
    "R_ELBOW_R",
    "R_WRIST_P",
    "R_WRIST_Y",
    "R_WRIST_R",
)

MASTER_FRAMES = {
    "base": "master_base",
    "shoulder": "master_shoulder",
    "elbow": "master_forearm",
    "wrist": "master_wrist",
    "ee": "master_ee",
}

SLAVE_FRAMES = {
    "base": "PELVIS_S",
    "shoulder": "R_SHOULDER_R_S",
    "elbow": "R_ELBOW_R_S",
    "wrist": "R_WRIST_R_S",
    "ee": "R_EE_SIM",
}

# Approximate simulated TCP taken from the terminal marker in right_arm.mjcf.
# This is deliberately not presented as a physical calibration.
SLAVE_SIM_TCP_OFFSET = np.array([-0.01212, -0.17655, 0.07506], dtype=float)


@dataclass(frozen=True)
class TeleoperationModels:
    master: pin.Model
    slave: pin.Model


def require_frame(model: pin.Model, name: str) -> int:
    """Resolve a frame and reject Pinocchio's not-found sentinel."""
    fid = int(model.getFrameId(name))
    if fid < 0 or fid >= model.nframes:
        raise ValueError(
            f"Frame {name!r} not found in model {model.name!r}; "
            f"available={[frame.name for frame in model.frames]}"
        )
    return fid


def require_joint(model: pin.Model, name: str) -> int:
    """Resolve an actuated joint and reject universe/not-found sentinels."""
    jid = int(model.getJointId(name))
    if jid <= 0 or jid >= model.njoints:
        raise ValueError(
            f"Joint {name!r} not found in model {model.name!r}; "
            f"available={list(model.names)[1:]}"
        )
    return jid


def joint_q_indices(model: pin.Model, names: Sequence[str]) -> np.ndarray:
    indices = []
    for name in names:
        jid = require_joint(model, name)
        joint = model.joints[jid]
        if joint.nq != 1 or joint.nv != 1:
            raise ValueError(f"Expected a scalar revolute joint, got {name!r}")
        indices.append(int(joint.idx_q))
    return np.asarray(indices, dtype=int)


def joint_v_indices(model: pin.Model, names: Sequence[str]) -> np.ndarray:
    indices = []
    for name in names:
        jid = require_joint(model, name)
        joint = model.joints[jid]
        if joint.nq != 1 or joint.nv != 1:
            raise ValueError(f"Expected a scalar revolute joint, got {name!r}")
        indices.append(int(joint.idx_v))
    return np.asarray(indices, dtype=int)


def validate_contract(
    model: pin.Model,
    joint_names: Sequence[str],
    frame_names: Iterable[str],
) -> None:
    if model.nq != 7 or model.nv != 7:
        raise ValueError(
            f"Expected a fixed-base 7-DoF model, got nq={model.nq}, nv={model.nv}"
        )
    joint_q_indices(model, joint_names)
    for frame_name in frame_names:
        require_frame(model, frame_name)


def add_fixed_operational_frame(
    model: pin.Model,
    *,
    name: str,
    parent_frame_name: str,
    translation: np.ndarray,
    rotation: np.ndarray | None = None,
) -> int:
    """Add an operational frame at a transform relative to an existing frame."""
    existing = int(model.getFrameId(name))
    if 0 <= existing < model.nframes:
        return existing

    parent_fid = require_frame(model, parent_frame_name)
    parent = model.frames[parent_fid]
    relative = pin.SE3(
        np.eye(3) if rotation is None else np.asarray(rotation, dtype=float),
        np.asarray(translation, dtype=float).reshape(3),
    )
    placement_in_parent_joint = parent.placement * relative
    return int(
        model.addFrame(
            pin.Frame(
                name,
                parent.parentJoint,
                parent_fid,
                placement_in_parent_joint,
                pin.FrameType.OP_FRAME,
            )
        )
    )


def load_models(*, add_simulated_tcp: bool = True) -> TeleoperationModels:
    """Load and validate the canonical master/slave dynamics models."""
    master = pin.buildModelFromUrdf(str(MASTER_URDF))
    slave = pin.buildModelFromUrdf(str(SLAVE_URDF))

    if add_simulated_tcp:
        add_fixed_operational_frame(
            slave,
            name=SLAVE_FRAMES["ee"],
            parent_frame_name=SLAVE_FRAMES["wrist"],
            translation=SLAVE_SIM_TCP_OFFSET,
        )

    validate_contract(master, MASTER_JOINT_NAMES, MASTER_FRAMES.values())
    validate_contract(slave, SLAVE_JOINT_NAMES, SLAVE_FRAMES.values())
    return TeleoperationModels(master=master, slave=slave)


def finite_joint_limits(
    model: pin.Model,
    joint_names: Sequence[str],
) -> tuple[np.ndarray, np.ndarray]:
    qidx = joint_q_indices(model, joint_names)
    lower = np.asarray(model.lowerPositionLimit[qidx], dtype=float)
    upper = np.asarray(model.upperPositionLimit[qidx], dtype=float)
    if not np.all(np.isfinite(lower)) or not np.all(np.isfinite(upper)):
        raise ValueError("All teleoperation joints must have finite position limits")
    if np.any(lower >= upper):
        raise ValueError("Invalid joint limits in teleoperation model")
    return lower, upper


def safe_configuration(
    model: pin.Model,
    joint_names: Sequence[str],
    *,
    fraction: float = 0.5,
    margin: float = 1e-3,
) -> np.ndarray:
    """Return a configuration strictly inside all declared joint limits."""
    if not 0.0 <= fraction <= 1.0:
        raise ValueError("fraction must lie in [0, 1]")
    lower, upper = finite_joint_limits(model, joint_names)
    span = upper - lower
    q7 = lower + fraction * span
    q7 = np.minimum(np.maximum(q7, lower + margin), upper - margin)
    q = pin.neutral(model)
    q[joint_q_indices(model, joint_names)] = q7
    return q


def clip_configuration(
    model: pin.Model,
    q: np.ndarray,
    joint_names: Sequence[str],
    *,
    margin: float = 1e-6,
) -> tuple[np.ndarray, bool]:
    """Clip scalar teleoperation joints and report whether clipping occurred."""
    q_clipped = np.asarray(q, dtype=float).copy()
    qidx = joint_q_indices(model, joint_names)
    lower, upper = finite_joint_limits(model, joint_names)
    clipped7 = np.minimum(np.maximum(q_clipped[qidx], lower + margin), upper - margin)
    changed = bool(np.any(np.abs(clipped7 - q_clipped[qidx]) > 1e-12))
    q_clipped[qidx] = clipped7
    return q_clipped, changed
