# haptic_render.py
# -*- coding: utf-8 -*-
from __future__ import annotations

from dataclasses import dataclass

import numpy as np

try:
    import pinocchio as pin
except ModuleNotFoundError:  # Pure supervisor tests do not require Pinocchio.
    pin = None


# ===== Final applied-port energy supervision =====

@dataclass
class TankParams:
    E_min: float = 0.5
    E_max: float = 20.0
    alpha_floor: float = 0.0
    alpha_ceil: float = 1.0
    power_epsilon: float = 1e-12

    # Legacy fields are kept so existing configuration/ablation code can load.
    # They are deliberately not used for energy accounting: filtering, a power
    # dead-zone, or alpha smoothing at this stage would make the tank account a
    # different signal from the torque actually applied at the master port.
    force_alpha: float = 0.2
    vel_alpha: float = 0.2
    alpha_smooth: float = 0.1
    power_deadzone: float = 0.0


@dataclass(frozen=True)
class AppliedPortDiagnostics:
    """Snapshot of one final-port projection/accounting step."""

    tau_candidate: np.ndarray
    tau_applied: np.ndarray
    rho: float
    E_before: float
    E_preclip: float
    E_after: float
    candidate_power: float
    power: float
    fail_safe_active: bool


class AppliedPortEnergySupervisor:
    """Passivity supervisor at the final master haptic-torque port.

    Positive ``tau_app @ qd_m`` is power delivered by the device to the
    operator and therefore discharges the tank.  Negative power charges the
    tank.  The candidate torque passed to :meth:`apply` must
    already include all nominal scaling, filtering, rate limiting, and actuator
    saturation.  The returned torque is final: downstream code must not filter,
    rescale, rate-limit, or saturate it.

    The projection is radial, ``tau_app = alpha * tau_candidate``.  This keeps a
    previously saturated candidate inside its actuator limits while imposing
    the exact one-step energy budget.  ``alpha_floor`` is never allowed to
    override the energy-safe upper bound.
    """

    def __init__(self, tank: TankParams | None = None, E0: float = 2.0):
        self.tp = TankParams() if tank is None else tank
        if not np.isfinite(self.tp.E_min) or not np.isfinite(self.tp.E_max):
            raise ValueError("E_min and E_max must be finite")
        if self.tp.E_max < self.tp.E_min:
            raise ValueError("E_max must be greater than or equal to E_min")
        if not 0.0 <= self.tp.alpha_ceil <= 1.0:
            raise ValueError("alpha_ceil must lie in [0, 1]")
        if not 0.0 <= self.tp.alpha_floor <= self.tp.alpha_ceil:
            raise ValueError("alpha_floor must lie in [0, alpha_ceil]")
        if self.tp.power_epsilon < 0.0:
            raise ValueError("power_epsilon must be non-negative")

        self.E = float(np.clip(E0, self.tp.E_min, self.tp.E_max))
        self.alpha = 1.0
        self.last_alpha = 1.0
        self.last_power = 0.0
        self.last_energy_before = self.E
        self.last_energy_after = self.E
        self.last_tau_candidate = np.empty(0, dtype=float)
        self.last_tau_applied = np.empty(0, dtype=float)
        self.fail_safe_active = False
        self.last_diagnostics: AppliedPortDiagnostics | None = None

    def reset(self, E0: float | None = None):
        if E0 is None:
            E0 = self.tp.E_min
        self.E = float(np.clip(E0, self.tp.E_min, self.tp.E_max))
        self.alpha = 1.0
        self.last_alpha = 1.0
        self.last_power = 0.0
        self.last_energy_before = self.E
        self.last_energy_after = self.E
        self.last_tau_candidate = np.empty(0, dtype=float)
        self.last_tau_applied = np.empty(0, dtype=float)
        self.fail_safe_active = False
        self.last_diagnostics = None

    def _record(self,
                tau_candidate: np.ndarray,
                tau_applied: np.ndarray,
                rho: float,
                E_before: float,
                E_preclip: float,
                E_after: float,
                candidate_power: float,
                power: float,
                fail_safe_active: bool) -> AppliedPortDiagnostics:
        """Record one result while retaining legacy scalar/state attributes."""
        diagnostics = AppliedPortDiagnostics(
            tau_candidate=tau_candidate.copy(),
            tau_applied=tau_applied.copy(),
            rho=float(rho),
            E_before=float(E_before),
            E_preclip=float(E_preclip),
            E_after=float(E_after),
            candidate_power=float(candidate_power),
            power=float(power),
            fail_safe_active=bool(fail_safe_active),
        )
        self.alpha = diagnostics.rho
        self.last_alpha = diagnostics.rho
        self.last_power = diagnostics.power
        self.last_energy_before = diagnostics.E_before
        self.last_energy_after = diagnostics.E_after
        self.last_tau_candidate = diagnostics.tau_candidate.copy()
        self.last_tau_applied = diagnostics.tau_applied.copy()
        self.fail_safe_active = diagnostics.fail_safe_active
        self.last_diagnostics = diagnostics
        return diagnostics

    def _zero_fail_safe(
        self,
        tau_candidate: np.ndarray,
    ) -> tuple[np.ndarray, AppliedPortDiagnostics]:
        tau_zero = np.zeros_like(tau_candidate, dtype=float)
        diagnostics = self._record(
            tau_candidate=tau_candidate,
            tau_applied=tau_zero,
            rho=0.0,
            E_before=self.E,
            E_preclip=self.E,
            E_after=self.E,
            candidate_power=0.0,
            power=0.0,
            fail_safe_active=True,
        )
        return tau_zero, diagnostics

    def apply(self,
              tau_raw: np.ndarray,
              qd: np.ndarray,
              dt: float) -> tuple[np.ndarray, AppliedPortDiagnostics]:
        """Return the final safe torque and diagnostics, accounting exactly once.

        ``tau_raw`` is the already shaped and actuator-limited candidate at the
        master joint port.  It may come directly from a mapping such as
        ``A.T @ tau_s`` or ``J_m.T @ F``.  ``qd`` is the velocity at that same
        port.  The returned ``tau_app`` must be sent unchanged to the haptic
        output (apart from an emergency zero-output fail-safe).
        """
        tau_candidate = np.asarray(tau_raw, dtype=float).reshape(-1,)
        qd_m = np.asarray(qd, dtype=float).reshape(-1,)
        if tau_candidate.shape != qd_m.shape:
            raise ValueError(
                "tau_raw and qd must have identical one-dimensional shapes"
            )
        if (
            tau_candidate.size == 0
            or not np.isfinite(dt)
            or dt <= 0.0
            or not np.all(np.isfinite(tau_candidate))
            or not np.all(np.isfinite(qd_m))
        ):
            return self._zero_fail_safe(tau_candidate)

        energy_before = self.E
        nominal_power = float(np.dot(tau_candidate, qd_m))
        if not np.isfinite(nominal_power):
            return self._zero_fail_safe(tau_candidate)
        rho = self.tp.alpha_ceil

        # Only device-to-human power consumes stored energy.  alpha_floor is a
        # preference, not a safety constraint, and cannot force an unsafe gain.
        if nominal_power > 0.0:
            available = max(0.0, energy_before - self.tp.E_min)
            rho_energy = available / (nominal_power * dt)
            rho = min(rho, max(0.0, rho_energy))

        tau_app = rho * tau_candidate
        applied_power = float(np.dot(tau_app, qd_m))

        # This is the sole tank update.  It uses the exact torque returned to
        # the caller and the exact master velocity supplied for this sample.
        energy_preclip = energy_before - applied_power * dt
        self.E = float(np.clip(energy_preclip, self.tp.E_min, self.tp.E_max))
        diagnostics = self._record(
            tau_candidate=tau_candidate,
            tau_applied=tau_app,
            rho=rho,
            E_before=energy_before,
            E_preclip=energy_preclip,
            E_after=self.E,
            candidate_power=nominal_power,
            power=applied_power,
            fail_safe_active=False,
        )
        return tau_app, diagnostics

    def project_and_account(self,
                            tau_candidate: np.ndarray,
                            qd_m: np.ndarray,
                            dt: float) -> tuple[float, np.ndarray]:
        """Compatibility wrapper around :meth:`apply`.

        Returns ``(alpha, tau_app)``.  The wrapper invokes ``apply`` exactly
        once, so it cannot introduce a second tank update.
        """
        tau_app, diagnostics = self.apply(tau_candidate, qd_m, dt)
        return diagnostics.rho, tau_app

    def observe_master(self, *_args, **_kwargs):
        """Reject the former second accounting path."""
        raise RuntimeError(
            "observe_master() is disabled: use apply() exactly once "
            "at the final applied master-torque port"
        )

    def enforce(self, *_args, **_kwargs):
        """Reject wrench/twist-port enforcement from the former implementation."""
        raise RuntimeError(
            "enforce(CF, VC, dt) is disabled: passivity is enforced on final "
            "master tau_app and qd_m via apply()"
        )


# Backward-compatible class name for existing imports.
EnergyTankPOPC = AppliedPortEnergySupervisor


# ===== 主端胸腔雅可比 =====

class ChestJacobianMaster:
    def __init__(self, model: pin.Model,
                 chest_frame_name: str,
                 ee_frame_name: str):
        if pin is None:
            raise ModuleNotFoundError(
                "Pinocchio is required to construct ChestJacobianMaster"
            )
        self.model = model
        self.data  = model.createData()
        self.fid_C  = model.getFrameId(chest_frame_name)
        self.fid_EE = model.getFrameId(ee_frame_name)

    def chest_jacobian(self, q_m, qd_m):
        pin.forwardKinematics(self.model, self.data, q_m, qd_m)
        pin.updateFramePlacements(self.model, self.data)

        # LOCAL_WORLD_ALIGNED expresses the twist at the EE point in axes
        # parallel to the world frame.  The spatial-vector convention used by
        # this project is [linear; angular].
        Jw = pin.computeFrameJacobian(self.model, self.data,
                                      q_m, self.fid_EE,
                                      pin.ReferenceFrame.LOCAL_WORLD_ALIGNED)

        # Rotate both vector blocks into chest axes, but do not translate the
        # reference point: the wrench paired with this Jacobian still acts at
        # the EE point.  A full spatial adjoint here would introduce a spurious
        # moment-arm term between the chest origin and EE point.
        oTC = self.data.oMf[self.fid_C]  # ^wT_C
        R_cw = oTC.rotation.T
        rotate_axes = np.zeros((6, 6))
        rotate_axes[:3, :3] = R_cw
        rotate_axes[3:, 3:] = R_cw
        CJ_m = rotate_axes @ Jw
        return CJ_m


# ===== 主端力反馈渲染 =====

class HapticRenderer:
    """Render interaction wrench as final, passivity-supervised master torque.

    The haptic path has one strict ordering:

    ``J_m.T @ F`` (direct baseline)
      -> optional nominal low-pass
      -> nominal sign/strength scaling
      -> torque-rate limiting
      -> actuator torque saturation
      -> final applied-port energy projection and single accounting update.

    No filtering or limiting is performed after the energy projection.  The
    second returned torque is therefore the exact ``tau_app`` used in
    ``tau_app @ qd_m`` for the tank update.
    """

    def __init__(self,
                 master_model: pin.Model,
                 chest_frame_name: str,
                 ee_frame_name: str,
                 feedback_strength: float,
                 E_init: float,
                 E_max: float,
                 alpha_floor: float,
                 alpha_ceil: float,
                 E0: float = 2.0,
                 torque_limit: float | np.ndarray | None = None,
                 torque_rate_limit: float | np.ndarray | None = None,
                 tau_filter_alpha: float = 0.2):
        # 约定 feedback_strength > 0，反作用时乘上一个负号
        self.feedback_strength = float(feedback_strength)
        if not np.isfinite(self.feedback_strength):
            raise ValueError("feedback_strength must be finite")

        self.CJ_master = ChestJacobianMaster(master_model,
                                             chest_frame_name,
                                             ee_frame_name)

        tank_params = TankParams(
            E_min=E_init,
            E_max=E_max,
            alpha_floor=alpha_floor,
            alpha_ceil=alpha_ceil,
        )
        self.tank = AppliedPortEnergySupervisor(tank_params, E0)

        # Nominal shaping state.  All of this is upstream of the energy
        # projection.  Public tau_alpha is retained for existing scripts.
        self.tau_alpha = float(tau_filter_alpha)
        if not 0.0 <= self.tau_alpha <= 1.0:
            raise ValueError("tau_filter_alpha must lie in [0, 1]")
        self.torque_limit = torque_limit
        self.torque_rate_limit = torque_rate_limit
        self._tau_fb_state = np.zeros(master_model.nv)
        self._tau_applied_prev = np.zeros(master_model.nv)
        self.last_rate_limit_active = False
        self.last_torque_saturation_active = False

    def reset_tank(self, E0: float = None):
        self.tank.reset(E0)
        self._tau_fb_state[:] = 0.0
        self._tau_applied_prev[:] = 0.0
        self.last_rate_limit_active = False
        self.last_torque_saturation_active = False

    # ---------- 主端逆动力学：计算 tau_master_current ----------

    def _inverse_dynamics(self,
                          q_m: np.ndarray,
                          qd_m: np.ndarray,
                          qdd_m: np.ndarray,
                          tau_ff_fric: np.ndarray | None = None) -> np.ndarray:
        """
        主端动力学模型：
            τ = M(q) qdd + C(q,qd) qd + g(q) + τ_ff_fric
        """
        q_m  = np.asarray(q_m,  dtype=float).reshape(-1,)
        qd_m = np.asarray(qd_m, dtype=float).reshape(-1,)
        qdd_m = np.asarray(qdd_m, dtype=float).reshape(-1,)
        if tau_ff_fric is not None:
            tau_ff_fric = np.asarray(tau_ff_fric, dtype=float).reshape(-1,)

        model = self.CJ_master.model
        data  = self.CJ_master.data

        # 惯量矩阵 M(q)
        M = pin.crba(model, data, q_m)
        M = (M + M.T) - np.diag(M.diagonal())   # 数值对称化

        # 非线性项（科氏/离心 + 重力）
        nle = pin.nonLinearEffects(model, data, q_m, qd_m)  # = C(q,qd)qd + g(q)
        g   = pin.computeGeneralizedGravity(model, data, q_m)
        Cqd = nle - g

        tau = M @ qdd_m + Cqd + g
        if tau_ff_fric is not None:
            tau = tau + tau_ff_fric
        return tau

    def compute_tau_master_current(self,
                                   q_m: np.ndarray,
                                   qd_m: np.ndarray,
                                   qdd_m: np.ndarray,
                                   tau_ff_fric: np.ndarray | None = None) -> np.ndarray:
        """
        对外接口：给定主端 (q, qd, qdd)，计算
            τ_master_current = M qdd + C qd + g + τ_ff_fric
        """
        return self._inverse_dynamics(q_m, qd_m, qdd_m, tau_ff_fric)

    # ---------- 从端交互力 → 主端反馈扭矩 ----------

    @staticmethod
    def _symmetric_limit_vector(value: float | np.ndarray | None,
                                size: int,
                                name: str) -> np.ndarray:
        if value is None:
            return np.full(size, np.inf, dtype=float)
        limit = np.asarray(value, dtype=float)
        if limit.ndim == 0:
            limit = np.full(size, float(limit), dtype=float)
        else:
            limit = limit.reshape(-1,)
            if limit.size != size:
                raise ValueError(f"{name} must be scalar or have {size} entries")
        if np.any(np.isnan(limit)) or np.any(limit < 0.0):
            raise ValueError(f"{name} must contain non-negative values")
        return limit

    def direct_from_CF(self,
                       q_m: np.ndarray,
                       qd_m: np.ndarray,
                       CF_int_slave_C: np.ndarray) -> np.ndarray:
        """Return the unmodified baseline mapping ``J_m.T @ F``.

        This interface intentionally applies no sign convention, nominal gain,
        filter, rate limit, saturation, or energy supervision.
        """
        CF = np.asarray(CF_int_slave_C, dtype=float).reshape(6,)
        CJ_m = self.CJ_master.chest_jacobian(q_m, qd_m)
        return np.asarray(CJ_m.T @ CF, dtype=float).reshape(-1,)

    # Explicitly named alias for experiment/baseline code.
    render_direct_from_CF = direct_from_CF

    def _compute_feedback_tau(self,
                              q_m: np.ndarray,
                              qd_m: np.ndarray,
                              CF_int_slave_C: np.ndarray,
                              V_slave_C: np.ndarray | None,
                              dt: float):
        """Legacy extension hook.

        The base implementation returns the direct mapping and ``None`` to
        request final applied-port supervision.  Existing no-tank subclasses
        that override this method and return a numeric alpha continue to bypass
        only the energy projection; all upstream nominal actuator shaping still
        applies.  ``V_slave_C`` is retained solely for call compatibility and is
        never used for tank accounting.
        """
        del V_slave_C, dt
        return self.direct_from_CF(q_m, qd_m, CF_int_slave_C), None

    def _shape_and_supervise(self,
                             tau_direct: np.ndarray,
                             qd_m: np.ndarray,
                             dt: float,
                             bypass_energy_projection: bool = False
                             ) -> tuple[np.ndarray, float]:
        """Apply the strictly ordered haptic output pipeline."""
        tau_saturated, valid = self._shape_candidate(tau_direct, qd_m, dt)
        if not valid:
            tau_zero, diagnostics = self.tank.apply(tau_direct, qd_m, dt)
            self._tau_applied_prev = tau_zero.copy()
            return tau_zero, diagnostics.rho

        # Final energy projection and the only accounting update.
        if bypass_energy_projection:
            tau_app = tau_saturated.copy()
            alpha = 1.0
        else:
            tau_app, diagnostics = self.tank.apply(tau_saturated, qd_m, dt)
            alpha = diagnostics.rho

        # Nothing may modify tau_app after projection.  Only state capture and
        # addition of the independent inverse-dynamics command occur downstream.
        self.commit_applied(tau_app)
        return tau_app, float(alpha)

    def _shape_candidate(
        self,
        tau_direct: np.ndarray,
        qd_m: np.ndarray,
        dt: float,
    ) -> tuple[np.ndarray, bool]:
        """Return the final upstream candidate without applying a supervisor.

        This split is used by the formal PO/PC baseline.  The caller must pass
        the candidate through exactly one supervisor and then invoke
        :meth:`commit_applied`; no additional filtering or limiting is allowed.
        """
        tau_direct = np.asarray(tau_direct, dtype=float).reshape(-1,)
        qd_m = np.asarray(qd_m, dtype=float).reshape(-1,)
        if tau_direct.shape != qd_m.shape:
            raise ValueError("direct haptic torque and qd_m must have equal shapes")

        if self._tau_fb_state.shape != tau_direct.shape:
            self._tau_fb_state = np.zeros_like(tau_direct)
        if self._tau_applied_prev.shape != tau_direct.shape:
            self._tau_applied_prev = np.zeros_like(tau_direct)

        # Invalid samples must not contaminate nominal filter/rate-limit state.
        if (
            not np.isfinite(dt)
            or dt <= 0.0
            or not np.all(np.isfinite(tau_direct))
            or not np.all(np.isfinite(qd_m))
        ):
            self.last_rate_limit_active = False
            self.last_torque_saturation_active = False
            return np.zeros_like(tau_direct), False

        # 1) Optional nominal torque filtering.
        a = float(self.tau_alpha)
        if not 0.0 <= a <= 1.0:
            raise ValueError("tau_alpha must lie in [0, 1]")
        self._tau_fb_state = (1.0 - a) * self._tau_fb_state + a * tau_direct

        # 2) Nominal sign and strength scaling.
        tau_nominal = -self.feedback_strength * self._tau_fb_state

        # 3) Per-joint rate limiting relative to the previously applied output.
        rate_limit = self._symmetric_limit_vector(
            self.torque_rate_limit, tau_nominal.size, "torque_rate_limit"
        )
        max_delta = rate_limit * dt
        tau_rate_limited = np.clip(
            tau_nominal,
            self._tau_applied_prev - max_delta,
            self._tau_applied_prev + max_delta,
        )
        self.last_rate_limit_active = bool(
            np.any(np.abs(tau_rate_limited - tau_nominal) > 1e-12)
        )

        # 4) Per-joint symmetric actuator saturation.
        torque_limit = self._symmetric_limit_vector(
            self.torque_limit, tau_nominal.size, "torque_limit"
        )
        tau_saturated = np.clip(tau_rate_limited, -torque_limit, torque_limit)
        self.last_torque_saturation_active = bool(
            np.any(np.abs(tau_saturated - tau_rate_limited) > 1e-12)
        )
        return tau_saturated, True

    def shape_mapped_reaction_candidate(
        self,
        tau_environment_on_master: np.ndarray,
        qd_m: np.ndarray,
        dt: float,
    ) -> tuple[np.ndarray, bool]:
        """Prepare the common upstream candidate for an external supervisor."""
        reaction = np.asarray(
            tau_environment_on_master,
            dtype=float,
        ).reshape(-1)
        return self._shape_candidate(-reaction, qd_m, dt)

    def commit_applied(self, tau_applied: np.ndarray) -> None:
        """Record the exact supervisor output used at the master port."""
        applied = np.asarray(tau_applied, dtype=float).reshape(-1)
        if applied.shape != self._tau_applied_prev.shape:
            raise ValueError("tau_applied has an unexpected shape")
        if not np.all(np.isfinite(applied)):
            raise ValueError("tau_applied must contain only finite values")
        self._tau_applied_prev = applied.copy()

    # ---------- 高层接口：算 tau_master_current + 反馈 ----------

    def render_mapped_reaction(
        self,
        tau_environment_on_master: np.ndarray,
        qd_m: np.ndarray,
        dt: float,
        *,
        supervise_energy: bool = True,
    ) -> tuple[np.ndarray, float]:
        """Shape an arbitrary generalized reaction such as ``A.T @ tau_s``.

        ``tau_environment_on_master`` already has the desired physical sign:
        it is the environment-on-device reaction and should oppose penetration.
        The historical renderer pipeline stores robot-on-environment action
        internally and inserts its own leading minus sign, so the conversion is
        performed exactly once here.  The returned torque is the final applied
        haptic output; callers must not filter or limit it downstream.
        """
        reaction = np.asarray(
            tau_environment_on_master,
            dtype=float,
        ).reshape(-1,)
        return self._shape_and_supervise(
            -reaction,
            qd_m,
            dt,
            bypass_energy_projection=not supervise_energy,
        )

    def render_tau(self,
                   q_m: np.ndarray,
                   qd_m: np.ndarray,
                   qdd_m: np.ndarray,
                   CF_int_slave_C: np.ndarray,
                   V_slave_C: np.ndarray | None,
                   dt: float,
                   tau_ff_fric: np.ndarray | None = None,
                   ):
        """
        高层接口：给定 (q, qd, qdd) 和从端交互力，返回最终主端总扭矩。

        输入：
        - q_m, qd_m, qdd_m: 主端关节位置/速度/加速度（用于逆动力学）
        - CF_int_slave_C: 从端 InteractionEstimator 估计出的 C F_int（6×1）
        - V_slave_C: 仅为旧调用兼容而保留；能量只按主端
          tau_app^T qd_m 计算
        - tau_ff_fric: 主端摩擦前馈（可为 None）
        - dt: 控制周期（秒）

        输出：
        - tau_cmd_m: 主端最终下发的总扭矩 = tau_master_current + tau_app
        - tau_app: 最终反馈扭矩，也是能量记账使用的精确力矩
        - alpha: 最终能量投影的径向缩放系数
        """
        # 1) 主端逆动力学扭矩
        tau_master_current = self._inverse_dynamics(q_m, qd_m, qdd_m, tau_ff_fric)
        tau_master_current = np.asarray(tau_master_current, dtype=float).reshape(-1,)

        # 2) Render and account the haptic contribution exactly once.
        tau_app, alpha = self.render_from_CF(
            q_m, qd_m,
            CF_int_slave_C=CF_int_slave_C,
            V_slave_C=V_slave_C,
            dt=dt,
        )

        # 3) tau_app is not modified after projection.
        tau_cmd_m = tau_master_current + tau_app
        return tau_cmd_m, tau_app, alpha

    # 兼容旧接口：只关心反馈扭矩
    def render_from_CF(self,
                       q_m: np.ndarray,
                       qd_m: np.ndarray,
                       CF_int_slave_C: np.ndarray,
                       V_slave_C: np.ndarray | None,
                       dt: float):
        """
        兼容旧接口：返回最终施加的反馈扭矩与能量投影系数。
        """
        tau_direct, legacy_alpha = self._compute_feedback_tau(
            q_m,
            qd_m,
            CF_int_slave_C,
            V_slave_C,
            dt,
        )
        return self._shape_and_supervise(
            tau_direct,
            qd_m,
            dt,
            bypass_energy_projection=legacy_alpha is not None,
        )
