"""
TDD Tests for PhysicsBody — Session-Persistent Thumb Kinematics.

Validates that the PhysicsBody correctly models:
- Handedness-dependent anchor positioning
- Session drift (posture changes over time)
- Fatigue accumulation and recovery
- Gaussian jitter on start positions
"""

import time

import pytest

from GramAddict.core.physics.biomechanics import PhysicsBody


@pytest.fixture(autouse=True)
def reset_singleton():
    """Reset the session singleton before each test."""
    PhysicsBody.reset()
    yield
    PhysicsBody.reset()


@pytest.fixture
def body_right():
    return PhysicsBody(handedness="right", device_info={"displayWidth": 1080, "displayHeight": 2400})


@pytest.fixture
def body_left():
    return PhysicsBody(handedness="left", device_info={"displayWidth": 1080, "displayHeight": 2400})


class TestHandedness:
    """Tests for handedness-dependent behavior."""

    def test_right_hander_anchor_is_right(self, body_right):
        """Right-hander anchor should be on the right side of the screen."""
        assert (
            body_right.anchor_x > body_right.w * 0.6
        ), f"Right-hander anchor_x should be > 60% of width, got {body_right.anchor_x}"

    def test_left_hander_anchor_is_left(self, body_left):
        """Left-hander anchor should be on the left side of the screen."""
        assert (
            body_left.anchor_x < body_left.w * 0.4
        ), f"Left-hander anchor_x should be < 40% of width, got {body_left.anchor_x}"

    def test_right_hander_scroll_starts_right(self, body_right):
        """Right-hander scroll positions should cluster on the right."""
        xs = [body_right.get_scroll_start()[0] for _ in range(50)]
        avg_x = sum(xs) / len(xs)
        assert avg_x > body_right.w * 0.55, f"Right-hander avg scroll X should be > 55% of width, got {avg_x:.0f}"

    def test_left_hander_scroll_starts_left(self, body_left):
        """Left-hander scroll positions should cluster on the left."""
        xs = [body_left.get_scroll_start()[0] for _ in range(50)]
        avg_x = sum(xs) / len(xs)
        assert avg_x < body_left.w * 0.45, f"Left-hander avg scroll X should be < 45% of width, got {avg_x:.0f}"


class TestThumbArcBias:
    """Tests for thumb arc direction."""

    def test_right_hander_arcs_right(self, body_right):
        """Right-hander arc bias should be positive (rightward)."""
        biases = [body_right.get_thumb_arc_bias() for _ in range(30)]
        avg_bias = sum(biases) / len(biases)
        assert avg_bias > 0, f"Right-hander arc should be positive, got {avg_bias:.2f}"

    def test_left_hander_arcs_left(self, body_left):
        """Left-hander arc bias should be negative (leftward)."""
        biases = [body_left.get_thumb_arc_bias() for _ in range(30)]
        avg_bias = sum(biases) / len(biases)
        assert avg_bias < 0, f"Left-hander arc should be negative, got {avg_bias:.2f}"


class TestSessionDrift:
    """Tests for posture-drift simulation."""

    def test_drift_is_zero_initially(self, body_right):
        """Drift should start at zero."""
        assert body_right.drift_x == 0.0
        assert body_right.drift_y == 0.0

    def test_drift_accumulates_over_many_gestures(self, body_right):
        """After many gestures, drift should accumulate."""
        for _ in range(500):
            body_right.get_scroll_start()

        # Drift applies every 15-25 gestures (randomized), so 500 guarantees multiple triggers
        total_drift = abs(body_right.drift_x) + abs(body_right.drift_y)
        assert total_drift > 0, "Expected non-zero drift after 500 gestures"

    def test_drift_is_bounded(self, body_right):
        """Drift should never wander off-screen."""
        for _ in range(500):
            body_right.get_scroll_start()

        assert abs(body_right.drift_x) <= body_right.w * 0.1 + 1, f"Drift X too large: {body_right.drift_x}"
        assert abs(body_right.drift_y) <= body_right.h * 0.06 + 1, f"Drift Y too large: {body_right.drift_y}"


class TestStartPositions:
    """Tests for scroll start position generation."""

    def test_positions_stay_within_screen_bounds(self, body_right):
        """All generated positions must be within safe screen margins."""
        for _ in range(200):
            x, y = body_right.get_scroll_start()
            assert 50 <= x <= body_right.w - 50, f"X out of bounds: {x}"
            assert 200 <= y <= body_right.h - 200, f"Y out of bounds: {y}"

    def test_positions_are_not_identical(self, body_right):
        """Consecutive positions should have variation (jitter)."""
        positions = [body_right.get_scroll_start() for _ in range(20)]
        xs = set(p[0] for p in positions)
        ys = set(p[1] for p in positions)

        assert len(xs) > 5, f"Expected position variation in X, got {len(xs)} unique values"
        assert len(ys) > 5, f"Expected position variation in Y, got {len(ys)} unique values"

    def test_gesture_count_increments(self, body_right):
        """Each scroll start should increment the gesture counter."""
        assert body_right.gesture_count == 0
        body_right.get_scroll_start()
        assert body_right.gesture_count == 1
        body_right.get_scroll_start()
        assert body_right.gesture_count == 2


class TestFatigue:
    """Tests for the fatigue model."""

    def test_fatigue_starts_at_zero(self, body_right):
        assert body_right.fatigue == 0.0

    def test_rapid_gestures_increase_fatigue(self, body_right):
        """Rapid sequential gestures should increase fatigue."""
        body_right.last_gesture_time = time.time()  # Just now
        body_right._update_fatigue()
        body_right.last_gesture_time = time.time() - 0.1  # 100ms ago
        body_right._update_fatigue()

        assert body_right.fatigue > 0, "Rapid gestures should increase fatigue"

    def test_idle_period_reduces_fatigue(self, body_right):
        """Long idle periods should reduce fatigue."""
        body_right.fatigue = 0.5
        body_right.last_gesture_time = time.time() - 10.0  # 10 seconds ago
        body_right._update_fatigue()

        assert body_right.fatigue < 0.5, "Idle period should reduce fatigue"

    def test_fatigue_is_clamped_0_to_1(self, body_right):
        """Fatigue should never exceed [0, 1]."""
        # Force rapid updates
        for _ in range(200):
            body_right.last_gesture_time = time.time() - 0.05
            body_right._update_fatigue()

        assert body_right.fatigue <= 1.0, f"Fatigue exceeded 1.0: {body_right.fatigue}"

        # Force recovery
        for _ in range(100):
            body_right.last_gesture_time = time.time() - 20.0
            body_right._update_fatigue()

        assert body_right.fatigue >= 0.0, f"Fatigue below 0.0: {body_right.fatigue}"


class TestTapPosition:
    """Tests for tap position generation."""

    def test_tap_position_near_target(self, body_right):
        """Tap position should be near the target coordinates."""
        target_x, target_y = 540, 1200
        for _ in range(50):
            x, y = body_right.get_tap_position(target_x, target_y)
            assert abs(x - target_x) < 25, f"Tap X too far: {abs(x - target_x)}px"
            assert abs(y - target_y) < 25, f"Tap Y too far: {abs(y - target_y)}px"

    def test_tap_stays_on_screen(self, body_right):
        """Tap positions must stay within screen bounds."""
        for _ in range(100):
            x, y = body_right.get_tap_position(10, 10)
            assert 5 <= x <= body_right.w - 5
            assert 5 <= y <= body_right.h - 5


class TestPressureAndTouchMajor:
    """Tests for pressure and touch contact area."""

    def test_pressure_baseline_in_range(self, body_right):
        for _ in range(50):
            p = body_right.get_pressure_baseline()
            assert 0.1 <= p <= 0.85, f"Pressure baseline out of range: {p}"

    def test_fatigue_increases_pressure(self, body_right):
        """Fatigued thumbs should press harder."""
        body_right.fatigue = 0.0
        pressures_fresh = [body_right.get_pressure_baseline() for _ in range(30)]

        body_right.fatigue = 0.8
        pressures_tired = [body_right.get_pressure_baseline() for _ in range(30)]

        avg_fresh = sum(pressures_fresh) / len(pressures_fresh)
        avg_tired = sum(pressures_tired) / len(pressures_tired)

        assert avg_tired > avg_fresh, f"Fatigued pressure ({avg_tired:.3f}) should exceed fresh ({avg_fresh:.3f})"

    def test_touch_major_in_range(self, body_right):
        for _ in range(50):
            tm = body_right.get_touch_major()
            assert 2 <= tm <= 20, f"Touch major out of range: {tm}"

    def test_fatigue_increases_touch_major(self, body_right):
        """Fatigued thumbs should have larger contact area."""
        body_right.fatigue = 0.0
        tm_fresh = [body_right.get_touch_major() for _ in range(30)]

        body_right.fatigue = 0.9
        tm_tired = [body_right.get_touch_major() for _ in range(30)]

        avg_fresh = sum(tm_fresh) / len(tm_fresh)
        avg_tired = sum(tm_tired) / len(tm_tired)

        assert avg_tired > avg_fresh, f"Fatigued touch_major ({avg_tired:.1f}) should exceed fresh ({avg_fresh:.1f})"


class TestSingleton:
    """Tests for the session singleton pattern."""

    def test_singleton_returns_same_instance(self):
        body1 = PhysicsBody.get_session_instance(device=None, handedness="right")
        body2 = PhysicsBody.get_session_instance()
        assert body1 is body2

    def test_reset_clears_singleton(self):
        body1 = PhysicsBody.get_session_instance(device=None, handedness="right")
        PhysicsBody.reset()
        body2 = PhysicsBody.get_session_instance(device=None, handedness="left")
        assert body1 is not body2
        assert body2.handedness == "left"
