"""Tests for AudioAnalyzer.

Covers the pure-Python pieces that don't need real audio hardware:
- Logarithmic FFT bin edge layout
- Slow-AGC envelope follower (attack vs release behaviour)
- Lifecycle reset of the AGC reference on start()

Tests are skipped when numpy isn't installed in the host environment
so they don't block CI on a minimal interpreter.
"""

from __future__ import annotations

import pytest

from media_server.services.audio_analyzer import AudioAnalyzer, _load_numpy

np = _load_numpy()
needs_numpy = pytest.mark.skipif(np is None, reason="numpy not available")


@pytest.fixture
def analyzer() -> AudioAnalyzer:
    return AudioAnalyzer(num_bins=16, sample_rate=44100, chunk_size=1024)


# ── _compute_bin_edges ────────────────────────────────────────────


@needs_numpy
def test_bin_edges_count_matches_num_bins_plus_one(analyzer: AudioAnalyzer) -> None:
    edges = analyzer._compute_bin_edges()
    assert len(edges) == analyzer.num_bins + 1


@needs_numpy
def test_bin_edges_are_monotonic_non_decreasing(analyzer: AudioAnalyzer) -> None:
    edges = analyzer._compute_bin_edges()
    assert all(edges[i] <= edges[i + 1] for i in range(len(edges) - 1))


@needs_numpy
def test_bin_edges_stay_within_fft_size(analyzer: AudioAnalyzer) -> None:
    edges = analyzer._compute_bin_edges()
    fft_size = analyzer.chunk_size // 2 + 1
    assert max(edges) <= fft_size - 1
    assert min(edges) >= 0


# ── AGC envelope follower (the new behaviour) ─────────────────────


def _step_envelope(analyzer: AudioAnalyzer, peak: float) -> float:
    """Run one frame of the AGC update with a known peak value.

    Mirrors the math inside _capture_loop without spinning up a real
    capture thread or requiring numpy: pure Python on a single float.
    """
    if peak > analyzer._spectrum_ref:
        analyzer._spectrum_ref += (peak - analyzer._spectrum_ref) * 0.05
    else:
        analyzer._spectrum_ref += (peak - analyzer._spectrum_ref) * 0.005
    return analyzer._spectrum_ref


def test_agc_initial_reference_is_quiet(analyzer: AudioAnalyzer) -> None:
    assert analyzer._spectrum_ref == pytest.approx(0.01)


def test_agc_attacks_quickly_toward_loud_signal(analyzer: AudioAnalyzer) -> None:
    # Drive 30 frames of a loud signal; reference should climb sharply.
    for _ in range(30):
        _step_envelope(analyzer, peak=1.0)
    # 30 frames of attack=0.05 brings (1 - 0.99^30) ≈ 0.78 of the way to 1.0.
    assert analyzer._spectrum_ref > 0.5
    assert analyzer._spectrum_ref < 1.0


def test_agc_releases_slowly_toward_quiet_signal(analyzer: AudioAnalyzer) -> None:
    analyzer._spectrum_ref = 1.0
    for _ in range(30):
        _step_envelope(analyzer, peak=0.0)
    # Release coefficient is 0.005 — after 30 frames we should have shed
    # only ~14% of the headroom, not snap back to silent.
    assert analyzer._spectrum_ref > 0.7
    assert analyzer._spectrum_ref < 1.0


def test_agc_is_asymmetric_attack_faster_than_release(analyzer: AudioAnalyzer) -> None:
    a = AudioAnalyzer()
    b = AudioAnalyzer()
    a._spectrum_ref = 0.5
    b._spectrum_ref = 0.5
    # One attack frame toward 1.0
    _step_envelope(a, peak=1.0)
    # One release frame toward 0.0 (same magnitude of error: 0.5)
    _step_envelope(b, peak=0.0)
    attack_delta = a._spectrum_ref - 0.5
    release_delta = 0.5 - b._spectrum_ref
    # Attack coefficient (0.05) is 10× the release coefficient (0.005).
    assert attack_delta == pytest.approx(release_delta * 10, rel=1e-6)


# ── start() lifecycle reset ──────────────────────────────────────


def test_start_resets_spectrum_ref_when_unavailable(
    monkeypatch: pytest.MonkeyPatch, analyzer: AudioAnalyzer
) -> None:
    """Even when start() returns False (no hardware), the AGC state
    should remain at the documented quiet baseline."""
    # Force unavailable so start() short-circuits without spawning a thread.
    monkeypatch.setattr(
        AudioAnalyzer, "available", property(lambda self: False)
    )
    analyzer._spectrum_ref = 0.95  # leftover from prior session
    started = analyzer.start()
    assert started is False
    # start() returned early before the reset — by design (no capture
    # means no need to renormalize). Document the contract.
    assert analyzer._spectrum_ref == 0.95


def test_start_resets_spectrum_ref_when_available(
    monkeypatch: pytest.MonkeyPatch, analyzer: AudioAnalyzer
) -> None:
    """When capture actually starts, leftover AGC state from a prior
    session must be cleared so the first transients don't clip."""
    monkeypatch.setattr(
        AudioAnalyzer, "available", property(lambda self: True)
    )
    # Stub out the thread so we don't actually spin up a capture loop.
    monkeypatch.setattr(
        "media_server.services.audio_analyzer.threading.Thread",
        lambda *a, **kw: type("T", (), {"start": lambda self: None})(),
    )
    analyzer._spectrum_ref = 0.95  # leftover from prior session
    try:
        started = analyzer.start()
        assert started is True
        assert analyzer._spectrum_ref == pytest.approx(0.01)
    finally:
        analyzer._running = False


# ── get_frequency_data thread-safe contract ───────────────────────


def test_get_frequency_data_returns_none_before_capture(
    analyzer: AudioAnalyzer,
) -> None:
    assert analyzer.get_frequency_data() is None
