"""Tests for AdaptiveTuner -- Sprint 8."""

from __future__ import annotations

import pytest

from arautopilot.core.adaptive_tuner import AdaptiveTuner
from arautopilot.core.pid_config import PidConfig, PidGains


def _make_config(
    adaptive_enabled: bool = True,
    adaptive_max_deviation_pct: float = 50.0,
) -> PidConfig:
    return PidConfig(
        inner_loop_base=PidGains(kp=1.0, ki=0.1, kd=0.05),
        outer_loop_base=PidGains(kp=2.0, ki=0.2, kd=0.1),
        adaptive_enabled=adaptive_enabled,
        adaptive_max_deviation_pct=adaptive_max_deviation_pct,
    )


def _make_tuner(
    kp: float = 2.0,
    ki: float = 0.4,
    adaptive_max_deviation_pct: float = 50.0,
    window_steps: int = 10,
    step_pct: float = 0.1,
) -> AdaptiveTuner:
    config = PidConfig(
        inner_loop_base=PidGains(kp=1.0, ki=0.1, kd=0.05),
        outer_loop_base=PidGains(kp=kp, ki=ki, kd=0.1),
        adaptive_enabled=True,
        adaptive_max_deviation_pct=adaptive_max_deviation_pct,
    )
    base = PidGains(kp=kp, ki=ki, kd=0.1)
    return AdaptiveTuner(config=config, base_gains=base, window_steps=window_steps, step_pct=step_pct)


class TestAdaptiveDisabled:
    def test_returns_none_when_disabled(self):
        config = _make_config(adaptive_enabled=False)
        tuner = AdaptiveTuner(
            config=config,
            base_gains=PidGains(kp=2.0, ki=0.2, kd=0.1),
        )
        for _ in range(200):
            result = tuner.step(5.0)
        assert result is None


class TestBufferFill:
    def test_returns_none_before_buffer_full(self):
        tuner = _make_tuner(window_steps=20)
        for i in range(19):
            assert tuner.step(5.0) is None

    def test_may_adapt_once_buffer_is_full(self):
        tuner = _make_tuner(window_steps=10, step_pct=0.1)
        result = None
        for _ in range(10):
            result = tuner.step(5.0)  # large persistent error → increase Kp
        # Should have triggered an adaptation on the 10th step
        assert result is not None


class TestOscillationDetection:
    def test_oscillating_signal_reduces_kp(self):
        tuner = _make_tuner(window_steps=10, kp=2.0, step_pct=0.1)
        original_kp = tuner._current_kp
        # Fill buffer with alternating signs (100% sign flips)
        result = None
        for i in range(10):
            err = 3.0 if i % 2 == 0 else -3.0
            result = tuner.step(err)
        # After oscillation detection, Kp should be reduced
        if result is not None:
            assert result.kp < original_kp

    def test_steady_error_increases_kp(self):
        tuner = _make_tuner(window_steps=10, kp=2.0, step_pct=0.1, ki=0.2)
        original_kp = tuner._current_kp
        result = None
        for _ in range(10):
            result = tuner.step(5.0)  # sustained positive error
        assert result is not None
        assert result.kp > original_kp

    def test_small_error_within_deadband_no_adapt(self):
        tuner = _make_tuner(window_steps=10)
        tuner.dead_band_deg = 2.0
        result = None
        for _ in range(10):
            result = tuner.step(0.5)  # within dead band
        assert result is None


class TestBoundsClamping:
    def test_kp_never_exceeds_upper_bound(self):
        tuner = _make_tuner(kp=2.0, adaptive_max_deviation_pct=20.0, window_steps=5, step_pct=0.5)
        for _ in range(200):
            tuner.step(10.0)  # large error, keep trying to increase
        assert tuner._current_kp <= 2.0 * 1.20 + 1e-9

    def test_kp_never_below_lower_bound(self):
        tuner = _make_tuner(kp=2.0, adaptive_max_deviation_pct=20.0, window_steps=5, step_pct=0.5)
        for i in range(200):
            err = 5.0 if i % 2 == 0 else -5.0
        assert tuner._current_kp >= 2.0 * 0.80 - 1e-9

    def test_ki_stays_within_bound(self):
        tuner = _make_tuner(kp=2.0, ki=0.4, adaptive_max_deviation_pct=30.0, window_steps=5, step_pct=0.5)
        for _ in range(200):
            tuner.step(10.0)
        assert tuner._current_ki <= 0.4 * 1.30 + 1e-9

    def test_kd_unchanged_after_adaptation(self):
        tuner = _make_tuner(window_steps=10, step_pct=0.1)
        original_kd = tuner._current_kd
        for _ in range(50):
            tuner.step(5.0)
        assert tuner._current_kd == pytest.approx(original_kd)


class TestKiRatioPreservation:
    def test_ki_kp_ratio_preserved_after_adapt(self):
        tuner = _make_tuner(kp=2.0, ki=0.4, window_steps=10, step_pct=0.1)
        base_ratio = 0.4 / 2.0
        result = None
        for _ in range(10):
            result = tuner.step(5.0)
        if result is not None:
            new_ratio = result.ki / result.kp
            assert new_ratio == pytest.approx(base_ratio, rel=0.01)


class TestReset:
    def test_reset_restores_base_gains(self):
        tuner = _make_tuner(kp=2.0, ki=0.4, window_steps=10, step_pct=0.1)
        for _ in range(10):
            tuner.step(5.0)
        tuner.reset()
        assert tuner._current_kp == pytest.approx(2.0)
        assert tuner._current_ki == pytest.approx(0.4)

    def test_reset_clears_buffer(self):
        tuner = _make_tuner(window_steps=10)
        for _ in range(8):
            tuner.step(5.0)
        tuner.reset()
        assert len(tuner._error_buffer) == 0

    def test_current_gains_property(self):
        tuner = _make_tuner(kp=2.0, ki=0.4)
        g = tuner.current_gains
        assert g.kp == pytest.approx(2.0)
        assert g.ki == pytest.approx(0.4)
