"""test_ambient_sound.py -- Unit tests for Issue #252 ambient sound classifier."""

from __future__ import annotations
import importlib.util, math, os, struct, sys, types
import pytest

# numpy is available on dev machine
import numpy as np


def _pkg_root():
    return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def _read_src(rel_path):
    with open(os.path.join(_pkg_root(), rel_path)) as f:
        return f.read()


def _import_mod():
    """Import ambient_sound_node without a live ROS2 environment."""
    for mod_name in ("rclpy", "rclpy.node", "rclpy.qos",
                     "std_msgs", "std_msgs.msg"):
        if mod_name not in sys.modules:
            sys.modules[mod_name] = types.ModuleType(mod_name)

    rclpy_node = sys.modules["rclpy.node"]
    rclpy_qos  = sys.modules["rclpy.qos"]
    std_msg    = sys.modules["std_msgs.msg"]

    DEFAULTS = {
        "sample_rate": 16000, "window_s": 1.0, "n_fft": 512, "n_mels": 32,
        "audio_topic": "/social/speech/audio_raw",
        "silence_db": -40.0, "alarm_db_min": -25.0, "alarm_zcr_min": 0.12,
        "alarm_high_ratio_min": 0.35, "speech_zcr_min": 0.02,
        "speech_zcr_max": 0.25, "speech_flatness_max": 0.35,
        "music_zcr_max": 0.08, "music_flatness_max": 0.25,
        "crowd_zcr_min": 0.10, "crowd_flatness_min": 0.35,
    }

    class _Node:
        def __init__(self, *a, **kw): pass
        def declare_parameter(self, *a, **kw): pass
        def get_parameter(self, name):
            class _P:
                value = DEFAULTS.get(name)
            return _P()
        def create_publisher(self, *a, **kw): return None
        def create_subscription(self, *a, **kw): return None
        def get_logger(self):
            class _L:
                def info(self, *a): pass
                def warn(self, *a): pass
                def error(self, *a): pass
            return _L()
        def destroy_node(self): pass

    rclpy_node.Node         = _Node
    rclpy_qos.QoSProfile    = type("QoSProfile", (), {"__init__": lambda s, **kw: None})
    std_msg.String          = type("String",  (), {"data": ""})
    std_msg.UInt8MultiArray = type("UInt8MultiArray", (), {"data": b""})
    sys.modules["rclpy"].init     = lambda *a, **kw: None
    sys.modules["rclpy"].spin     = lambda n: None
    sys.modules["rclpy"].ok       = lambda: True
    sys.modules["rclpy"].shutdown = lambda: None

    spec = importlib.util.spec_from_file_location(
        "ambient_sound_node_testmod",
        os.path.join(_pkg_root(), "saltybot_social", "ambient_sound_node.py"),
    )
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


# ── Audio helpers ─────────────────────────────────────────────────────────────

SR = 16000

def _sine(freq, n=SR, amp=0.2):
    return [amp * math.sin(2 * math.pi * freq * i / SR) for i in range(n)]

def _white_noise(n=SR, amp=0.1):
    import random
    rng = random.Random(42)
    return [rng.uniform(-amp, amp) for _ in range(n)]

def _silence(n=SR):
    return [0.0] * n

def _pcm16(samples):
    ints = [max(-32768, min(32767, int(s * 32768))) for s in samples]
    return struct.pack(f"<{len(ints)}h", *ints)


# ── TestPcm16Convert ──────────────────────────────────────────────────────────

class TestPcm16Convert:
    @pytest.fixture(scope="class")
    def mod(self): return _import_mod()

    def test_empty(self, mod):
        assert mod.pcm16_bytes_to_float32(b"") == []

    def test_length(self, mod):
        data = _pcm16(_sine(440, 480))
        assert len(mod.pcm16_bytes_to_float32(data)) == 480

    def test_range(self, mod):
        data = _pcm16(_sine(440, 480))
        result = mod.pcm16_bytes_to_float32(data)
        assert all(-1.0 <= s <= 1.0 for s in result)

    def test_silence(self, mod):
        data = _pcm16(_silence(100))
        assert all(s == 0.0 for s in mod.pcm16_bytes_to_float32(data))


# ── TestMelConversions ────────────────────────────────────────────────────────

class TestMelConversions:
    @pytest.fixture(scope="class")
    def mod(self): return _import_mod()

    def test_hz_to_mel_zero(self, mod):
        assert mod.hz_to_mel(0.0) == 0.0

    def test_hz_to_mel_1000(self, mod):
        # 1000 Hz → ~999.99 mel (approximately)
        assert abs(mod.hz_to_mel(1000.0) - 999.99) < 1.0

    def test_roundtrip(self, mod):
        for hz in (100.0, 500.0, 1000.0, 4000.0, 8000.0):
            assert abs(mod.mel_to_hz(mod.hz_to_mel(hz)) - hz) < 0.01

    def test_monotone_increasing(self, mod):
        freqs = [100, 500, 1000, 2000, 4000, 8000]
        mels = [mod.hz_to_mel(f) for f in freqs]
        assert mels == sorted(mels)


# ── TestMelFilterbank ─────────────────────────────────────────────────────────

class TestMelFilterbank:
    @pytest.fixture(scope="class")
    def mod(self): return _import_mod()

    def test_shape(self, mod):
        fb = mod.build_mel_filterbank(SR, 512, 32)
        assert fb.shape == (32, 257)   # (n_mels, n_fft//2+1)

    def test_nonnegative(self, mod):
        fb = mod.build_mel_filterbank(SR, 512, 32)
        assert (fb >= 0).all()

    def test_each_filter_sums_positive(self, mod):
        fb = mod.build_mel_filterbank(SR, 512, 32)
        assert all(fb[m].sum() > 0 for m in range(32))

    def test_custom_n_mels(self, mod):
        fb = mod.build_mel_filterbank(SR, 512, 16)
        assert fb.shape[0] == 16

    def test_max_value_leq_one(self, mod):
        fb = mod.build_mel_filterbank(SR, 512, 32)
        assert fb.max() <= 1.0 + 1e-6


# ── TestMelSpectrogram ────────────────────────────────────────────────────────

class TestMelSpectrogram:
    @pytest.fixture(scope="class")
    def mod(self): return _import_mod()

    def test_shape(self, mod):
        s = _sine(440, SR)
        spec = mod.compute_mel_spectrogram(s, SR, n_fft=512, n_mels=32, hop_length=256)
        assert spec.shape[0] == 32
        assert spec.shape[1] > 0

    def test_silence_near_zero(self, mod):
        spec = mod.compute_mel_spectrogram(_silence(SR), SR, n_fft=512, n_mels=32)
        assert spec.mean() < 1e-6

    def test_louder_has_higher_energy(self, mod):
        quiet = mod.compute_mel_spectrogram(_sine(440, SR, amp=0.01), SR).mean()
        loud  = mod.compute_mel_spectrogram(_sine(440, SR, amp=0.5),  SR).mean()
        assert loud > quiet

    def test_returns_array(self, mod):
        spec = mod.compute_mel_spectrogram(_sine(440, SR), SR)
        assert isinstance(spec, np.ndarray)


# ── TestExtractFeatures ───────────────────────────────────────────────────────

class TestExtractFeatures:
    @pytest.fixture(scope="class")
    def mod(self): return _import_mod()

    def _feats(self, mod, samples):
        return mod.extract_features(samples, SR, n_fft=512, n_mels=32)

    def test_keys_present(self, mod):
        f = self._feats(mod, _sine(440, SR))
        for k in ("energy_db", "zcr", "mel_centroid", "mel_flatness",
                  "low_ratio", "high_ratio"):
            assert k in f

    def test_silence_low_energy(self, mod):
        f = self._feats(mod, _silence(SR))
        assert f["energy_db"] < -40.0

    def test_silence_zero_zcr(self, mod):
        f = self._feats(mod, _silence(SR))
        assert f["zcr"] == 0.0

    def test_sine_moderate_energy(self, mod):
        f = self._feats(mod, _sine(440, SR, amp=0.1))
        assert -40.0 < f["energy_db"] < 0.0

    def test_ratios_sum_leq_one(self, mod):
        f = self._feats(mod, _sine(440, SR))
        assert f["low_ratio"] + f["high_ratio"] <= 1.0 + 1e-6

    def test_ratios_nonnegative(self, mod):
        f = self._feats(mod, _sine(440, SR))
        assert f["low_ratio"] >= 0.0 and f["high_ratio"] >= 0.0

    def test_flatness_in_unit_interval(self, mod):
        f = self._feats(mod, _sine(440, SR))
        assert 0.0 <= f["mel_flatness"] <= 1.0

    def test_white_noise_high_flatness(self, mod):
        f_noise = self._feats(mod, _white_noise(SR, amp=0.3))
        f_sine  = self._feats(mod, _sine(440, SR, amp=0.3))
        # White noise should have higher spectral flatness than a pure tone
        assert f_noise["mel_flatness"] > f_sine["mel_flatness"]

    def test_empty_samples(self, mod):
        f = mod.extract_features([], SR)
        assert f["energy_db"] == 0.0

    def test_louder_higher_energy_db(self, mod):
        quiet = self._feats(mod, _sine(440, SR, amp=0.01))["energy_db"]
        loud  = self._feats(mod, _sine(440, SR, amp=0.5))["energy_db"]
        assert loud > quiet


# ── TestClassifier ────────────────────────────────────────────────────────────

class TestClassifier:
    @pytest.fixture(scope="class")
    def mod(self): return _import_mod()

    def _cls(self, mod, **feat_overrides):
        base = {"energy_db": -20.0, "zcr": 0.05,
                "mel_centroid": 0.4, "mel_flatness": 0.2,
                "low_ratio": 0.4, "high_ratio": 0.2}
        base.update(feat_overrides)
        return mod.classify(base)

    def test_silence(self, mod):
        assert self._cls(mod, energy_db=-45.0) == "silence"

    def test_silence_at_threshold(self, mod):
        assert self._cls(mod, energy_db=-40.0) != "silence"

    def test_alarm(self, mod):
        assert self._cls(mod, energy_db=-20.0, zcr=0.15, high_ratio=0.40) == "alarm"

    def test_alarm_requires_high_ratio(self, mod):
        result = self._cls(mod, energy_db=-20.0, zcr=0.15, high_ratio=0.10)
        assert result != "alarm"

    def test_speech(self, mod):
        assert self._cls(mod, energy_db=-25.0, zcr=0.08,
                         mel_flatness=0.20) == "speech"

    def test_speech_zcr_too_low(self, mod):
        result = self._cls(mod, energy_db=-25.0, zcr=0.005, mel_flatness=0.2)
        assert result != "speech"

    def test_speech_zcr_too_high(self, mod):
        result = self._cls(mod, energy_db=-25.0, zcr=0.30, mel_flatness=0.2)
        assert result != "speech"

    def test_music(self, mod):
        assert self._cls(mod, energy_db=-25.0, zcr=0.04,
                         mel_flatness=0.10) == "music"

    def test_crowd(self, mod):
        assert self._cls(mod, energy_db=-25.0, zcr=0.15,
                         mel_flatness=0.40) == "crowd"

    def test_outdoor_catchall(self, mod):
        # Moderate energy, mid ZCR, mid flatness → outdoor
        result = self._cls(mod, energy_db=-35.0, zcr=0.06, mel_flatness=0.30)
        assert result in mod.LABELS

    def test_returns_valid_label(self, mod):
        import random
        rng = random.Random(0)
        for _ in range(20):
            f = {
                "energy_db":    rng.uniform(-60, 0),
                "zcr":          rng.uniform(0, 0.5),
                "mel_centroid": rng.uniform(0, 1),
                "mel_flatness": rng.uniform(0, 1),
                "low_ratio":    rng.uniform(0, 0.6),
                "high_ratio":   rng.uniform(0, 0.4),
            }
            assert mod.classify(f) in mod.LABELS


# ── TestAudioBuffer ───────────────────────────────────────────────────────────

class TestAudioBuffer:
    @pytest.fixture(scope="class")
    def mod(self): return _import_mod()

    def test_no_window_until_full(self, mod):
        buf = mod.AudioBuffer(window_samples=100)
        assert buf.push([0.0] * 50) is None

    def test_exact_fill_returns_window(self, mod):
        buf = mod.AudioBuffer(window_samples=100)
        w = buf.push([0.0] * 100)
        assert w is not None and len(w) == 100

    def test_overflow_carries_over(self, mod):
        buf = mod.AudioBuffer(window_samples=100)
        buf.push([0.0] * 100)         # fills first window
        w2 = buf.push([1.0] * 100)   # fills second window
        assert w2 is not None and len(w2) == 100

    def test_partial_then_complete(self, mod):
        buf = mod.AudioBuffer(window_samples=100)
        buf.push([0.0] * 60)
        w = buf.push([0.0] * 60)
        assert w is not None and len(w) == 100

    def test_clear_resets(self, mod):
        buf = mod.AudioBuffer(window_samples=100)
        buf.push([0.0] * 90)
        buf.clear()
        assert buf.push([0.0] * 90) is None

    def test_window_contents_correct(self, mod):
        buf = mod.AudioBuffer(window_samples=4)
        w = buf.push([1.0, 2.0, 3.0, 4.0])
        assert w == [1.0, 2.0, 3.0, 4.0]


# ── TestNodeSrc ───────────────────────────────────────────────────────────────

class TestNodeSrc:
    @pytest.fixture(scope="class")
    def src(self): return _read_src("saltybot_social/ambient_sound_node.py")

    def test_class_defined(self, src):          assert "class AmbientSoundNode" in src
    def test_audio_buffer(self, src):           assert "class AudioBuffer" in src
    def test_extract_features(self, src):       assert "def extract_features" in src
    def test_classify_fn(self, src):            assert "def classify" in src
    def test_mel_spectrogram(self, src):        assert "compute_mel_spectrogram" in src
    def test_mel_filterbank(self, src):         assert "build_mel_filterbank" in src
    def test_hz_to_mel(self, src):              assert "hz_to_mel" in src
    def test_labels_tuple(self, src):           assert "LABELS" in src
    def test_all_labels(self, src):
        for label in ("silence", "speech", "music", "crowd", "outdoor", "alarm"):
            assert label in src
    def test_topic_pub(self, src):              assert '"/saltybot/ambient_sound"' in src
    def test_topic_sub(self, src):              assert '"/social/speech/audio_raw"' in src
    def test_window_param(self, src):           assert '"window_s"' in src
    def test_n_mels_param(self, src):           assert '"n_mels"' in src
    def test_silence_param(self, src):          assert '"silence_db"' in src
    def test_alarm_param(self, src):            assert '"alarm_db_min"' in src
    def test_speech_param(self, src):           assert '"speech_zcr_min"' in src
    def test_music_param(self, src):            assert '"music_zcr_max"' in src
    def test_crowd_param(self, src):            assert '"crowd_zcr_min"' in src
    def test_string_pub(self, src):             assert "String" in src
    def test_uint8_sub(self, src):              assert "UInt8MultiArray" in src
    def test_issue_tag(self, src):              assert "252" in src
    def test_main(self, src):                   assert "def main" in src
    def test_numpy_optional(self, src):         assert "_NUMPY" in src


# ── TestConfig ────────────────────────────────────────────────────────────────

class TestConfig:
    @pytest.fixture(scope="class")
    def cfg(self): return _read_src("config/ambient_sound_params.yaml")

    @pytest.fixture(scope="class")
    def setup(self): return _read_src("setup.py")

    def test_node_name(self, cfg):        assert "ambient_sound_node:" in cfg
    def test_window_s(self, cfg):         assert "window_s" in cfg
    def test_n_mels(self, cfg):           assert "n_mels" in cfg
    def test_silence_db(self, cfg):       assert "silence_db" in cfg
    def test_alarm_params(self, cfg):     assert "alarm_db_min" in cfg
    def test_speech_params(self, cfg):    assert "speech_zcr_min" in cfg
    def test_music_params(self, cfg):     assert "music_zcr_max" in cfg
    def test_crowd_params(self, cfg):     assert "crowd_zcr_min" in cfg
    def test_defaults_present(self, cfg): assert "-40.0" in cfg and "0.12" in cfg
    def test_entry_point(self, setup):
        assert "ambient_sound_node = saltybot_social.ambient_sound_node:main" in setup
