#!/usr/bin/env python3
"""Unit tests for final applied-port haptic energy supervision.

These tests use no URDF, simulator, or Pinocchio model.  They exercise the
energy supervisor and the renderer's nominal shaping order directly.
"""

from __future__ import annotations

import os
import sys
import unittest

import numpy as np

CODE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if CODE_DIR not in sys.path:
    sys.path.insert(0, CODE_DIR)

from core.haptic_render import (  # noqa: E402
    AppliedPortDiagnostics,
    AppliedPortEnergySupervisor,
    HapticRenderer,
    TankParams,
)


def make_supervisor(E0: float = 3.0,
                    E_min: float = 1.0,
                    E_max: float = 5.0) -> AppliedPortEnergySupervisor:
    return AppliedPortEnergySupervisor(
        TankParams(
            E_min=E_min,
            E_max=E_max,
            alpha_floor=0.0,
            alpha_ceil=1.0,
        ),
        E0=E0,
    )


def make_renderer_state(size: int,
                        *,
                        E0: float = 3.0,
                        feedback_strength: float = 1.0,
                        torque_limit=None,
                        torque_rate_limit=None) -> HapticRenderer:
    """Construct the pure torque pipeline without a Pinocchio dependency."""
    renderer = object.__new__(HapticRenderer)
    renderer.feedback_strength = float(feedback_strength)
    renderer.tank = make_supervisor(E0=E0)
    renderer.tau_alpha = 1.0
    renderer.torque_limit = torque_limit
    renderer.torque_rate_limit = torque_rate_limit
    renderer._tau_fb_state = np.zeros(size, dtype=float)
    renderer._tau_applied_prev = np.zeros(size, dtype=float)
    return renderer


class FakeChestJacobian:
    def __init__(self, jacobian: np.ndarray):
        self.jacobian = np.asarray(jacobian, dtype=float)

    def chest_jacobian(self, _q_m, _qd_m):
        return self.jacobian.copy()


class AppliedPortEnergySupervisorTest(unittest.TestCase):
    def test_low_level_apply_returns_complete_diagnostics(self):
        supervisor = make_supervisor(E0=3.0)
        candidate = np.array([2.0, -1.0])
        tau_app, diagnostics = supervisor.apply(
            candidate,
            np.array([0.5, 1.0]),
            dt=0.25,
        )

        self.assertIsInstance(diagnostics, AppliedPortDiagnostics)
        np.testing.assert_allclose(diagnostics.tau_candidate, candidate)
        np.testing.assert_allclose(diagnostics.tau_applied, tau_app)
        self.assertAlmostEqual(diagnostics.rho, 1.0)
        self.assertAlmostEqual(diagnostics.E_before, 3.0)
        self.assertAlmostEqual(diagnostics.E_preclip, 3.0)
        self.assertAlmostEqual(diagnostics.candidate_power, 0.0)
        self.assertAlmostEqual(diagnostics.power, 0.0)
        self.assertAlmostEqual(diagnostics.E_after, 3.0)
        self.assertFalse(diagnostics.fail_safe_active)
        self.assertIs(supervisor.last_diagnostics, diagnostics)

    def test_positive_power_discharges_once(self):
        supervisor = make_supervisor(E0=3.0)
        alpha, tau_app = supervisor.project_and_account(
            np.array([2.0, 0.0]),
            np.array([1.0, 0.0]),
            dt=0.25,
        )

        self.assertAlmostEqual(alpha, 1.0)
        np.testing.assert_allclose(tau_app, [2.0, 0.0])
        self.assertAlmostEqual(supervisor.last_power, 2.0)
        self.assertAlmostEqual(supervisor.E, 2.5)

    def test_negative_power_charges_once(self):
        supervisor = make_supervisor(E0=3.0)
        alpha, tau_app = supervisor.project_and_account(
            np.array([-2.0, 1.0]),
            np.array([1.0, 0.0]),
            dt=0.25,
        )

        self.assertAlmostEqual(alpha, 1.0)
        np.testing.assert_allclose(tau_app, [-2.0, 1.0])
        self.assertAlmostEqual(supervisor.last_power, -2.0)
        self.assertAlmostEqual(supervisor.E, 3.5)

    def test_zero_power_does_not_change_energy_or_torque(self):
        supervisor = make_supervisor(E0=3.0)
        candidate = np.array([2.0, -1.0])
        alpha, tau_app = supervisor.project_and_account(
            candidate,
            np.zeros(2),
            dt=0.25,
        )

        self.assertAlmostEqual(alpha, 1.0)
        np.testing.assert_allclose(tau_app, candidate)
        self.assertAlmostEqual(supervisor.last_power, 0.0)
        self.assertAlmostEqual(supervisor.E, 3.0)

    def test_energy_projection_hits_E_min_exactly(self):
        supervisor = make_supervisor(E0=1.25)
        alpha, tau_app = supervisor.project_and_account(
            np.array([2.0]),
            np.array([1.0]),
            dt=0.5,
        )

        self.assertAlmostEqual(alpha, 0.25)
        np.testing.assert_allclose(tau_app, [0.5])
        self.assertAlmostEqual(supervisor.last_power, 0.5)
        self.assertAlmostEqual(supervisor.E, 1.0)

        alpha_at_min, tau_at_min = supervisor.project_and_account(
            np.array([4.0]),
            np.array([1.0]),
            dt=0.1,
        )
        self.assertAlmostEqual(alpha_at_min, 0.0)
        np.testing.assert_allclose(tau_at_min, [0.0])
        self.assertAlmostEqual(supervisor.E, 1.0)

    def test_nonpositive_dt_uses_zero_fail_safe_without_accounting(self):
        supervisor = make_supervisor(E0=3.0)
        alpha, tau_app = supervisor.project_and_account(
            np.array([2.0]),
            np.array([1.0]),
            dt=0.0,
        )

        self.assertAlmostEqual(alpha, 0.0)
        np.testing.assert_allclose(tau_app, [0.0])
        self.assertTrue(supervisor.fail_safe_active)
        self.assertAlmostEqual(supervisor.E, 3.0)


class HapticRendererPipelineTest(unittest.TestCase):
    def test_external_supervisor_candidate_requires_explicit_commit(self):
        renderer = make_renderer_state(
            1,
            torque_limit=2.0,
            torque_rate_limit=10.0,
        )
        candidate, valid = renderer.shape_mapped_reaction_candidate(
            np.array([-10.0]),
            qd_m=np.zeros(1),
            dt=0.1,
        )
        self.assertTrue(valid)
        np.testing.assert_allclose(candidate, [-1.0])
        np.testing.assert_allclose(renderer._tau_applied_prev, [0.0])
        renderer.commit_applied(np.array([0.25]))
        np.testing.assert_allclose(renderer._tau_applied_prev, [0.25])

    def test_generalized_reaction_keeps_environment_on_device_sign(self):
        renderer = make_renderer_state(2)
        reaction = np.array([-2.0, 1.0])

        tau_app, rho = renderer.render_mapped_reaction(
            reaction,
            qd_m=np.zeros(2),
            dt=0.01,
        )

        np.testing.assert_allclose(tau_app, reaction)
        self.assertAlmostEqual(rho, 1.0)

    def test_direct_baseline_is_exact_J_transpose_F(self):
        renderer = object.__new__(HapticRenderer)
        jacobian = np.arange(12, dtype=float).reshape(6, 2) / 10.0
        renderer.CJ_master = FakeChestJacobian(jacobian)
        wrench = np.array([1.0, -2.0, 0.5, 3.0, -1.0, 2.0])

        tau_direct = renderer.direct_from_CF(
            q_m=np.zeros(2),
            qd_m=np.zeros(2),
            CF_int_slave_C=wrench,
        )

        np.testing.assert_allclose(tau_direct, jacobian.T @ wrench)

    def test_rate_limit_precedes_energy_projection(self):
        renderer = make_renderer_state(
            2,
            torque_rate_limit=np.array([2.0, 1.0]),
        )

        tau_step_1, alpha_1 = renderer._shape_and_supervise(
            tau_direct=np.array([-10.0, 10.0]),
            qd_m=np.zeros(2),
            dt=0.1,
        )
        tau_step_2, alpha_2 = renderer._shape_and_supervise(
            tau_direct=np.array([-10.0, 10.0]),
            qd_m=np.zeros(2),
            dt=0.1,
        )

        np.testing.assert_allclose(tau_step_1, [0.2, -0.1])
        np.testing.assert_allclose(tau_step_2, [0.4, -0.2])
        self.assertAlmostEqual(alpha_1, 1.0)
        self.assertAlmostEqual(alpha_2, 1.0)
        self.assertTrue(renderer.last_rate_limit_active)
        self.assertFalse(renderer.last_torque_saturation_active)
        np.testing.assert_allclose(
            renderer.tank.last_tau_candidate, tau_step_2
        )

    def test_saturation_precedes_energy_projection(self):
        renderer = make_renderer_state(
            2,
            torque_limit=np.array([3.0, 4.0]),
        )

        tau_app, alpha = renderer._shape_and_supervise(
            tau_direct=np.array([-10.0, 10.0]),
            qd_m=np.zeros(2),
            dt=0.1,
        )

        self.assertAlmostEqual(alpha, 1.0)
        np.testing.assert_allclose(tau_app, [3.0, -4.0])
        self.assertFalse(renderer.last_rate_limit_active)
        self.assertTrue(renderer.last_torque_saturation_active)
        np.testing.assert_allclose(
            renderer.tank.last_tau_candidate, [3.0, -4.0]
        )

    def test_projection_is_last_and_uses_saturated_candidate(self):
        renderer = make_renderer_state(
            1,
            E0=1.1,
            torque_limit=2.0,
        )

        tau_app, alpha = renderer._shape_and_supervise(
            tau_direct=np.array([-10.0]),
            qd_m=np.array([1.0]),
            dt=1.0,
        )

        # Direct -> nominal +10 -> saturation +2 -> energy projection +0.1.
        self.assertAlmostEqual(alpha, 0.05)
        np.testing.assert_allclose(renderer.tank.last_tau_candidate, [2.0])
        np.testing.assert_allclose(renderer.tank.last_tau_applied, [0.1])
        np.testing.assert_allclose(tau_app, renderer.tank.last_tau_applied)
        np.testing.assert_allclose(renderer._tau_applied_prev, tau_app)
        self.assertAlmostEqual(renderer.tank.last_power, float(tau_app[0]))
        self.assertAlmostEqual(renderer.tank.E, 1.0)

    def test_stepwise_offline_energy_reconstruction_matches_exactly(self):
        renderer = make_renderer_state(
            2,
            E0=2.5,
            feedback_strength=1.5,
            torque_limit=np.array([2.0, 1.5]),
            torque_rate_limit=np.array([4.0, 3.0]),
        )
        dt = 0.1
        direct_sequence = [
            np.array([-2.0, 1.0]),
            np.array([-4.0, 2.0]),
            np.array([1.0, -2.0]),
            np.array([0.0, 0.0]),
            np.array([-5.0, -5.0]),
            np.array([2.0, 2.0]),
        ]
        velocity_sequence = [
            np.array([1.0, 0.5]),
            np.array([0.7, -0.4]),
            np.array([1.0, 1.0]),
            np.zeros(2),
            np.array([-0.6, -0.2]),
            np.array([0.5, -0.8]),
        ]

        energy_offline = 2.5
        for tau_direct, qd_m in zip(direct_sequence, velocity_sequence):
            tau_app, _ = renderer._shape_and_supervise(
                tau_direct=tau_direct,
                qd_m=qd_m,
                dt=dt,
            )
            applied_power = float(np.dot(tau_app, qd_m))
            energy_offline = float(np.clip(
                energy_offline - applied_power * dt,
                renderer.tank.tp.E_min,
                renderer.tank.tp.E_max,
            ))

            np.testing.assert_allclose(
                renderer.tank.last_tau_applied, tau_app
            )
            self.assertAlmostEqual(renderer.tank.last_power, applied_power)
            self.assertAlmostEqual(renderer.tank.E, energy_offline, places=14)


if __name__ == "__main__":
    unittest.main()
