#!/usr/bin/env python3
"""Unit tests for saltybot_audio_pipeline (Issue #503)."""

import unittest
import numpy as np
import threading


class TestEnergyVAD(unittest.TestCase):
    def setUp(self):
        from saltybot_audio_pipeline.audio_utils import EnergyVAD
        self.vad = EnergyVAD(threshold_db=-35.0)

    def test_silence_is_not_speech(self):
        self.assertFalse(self.vad.is_speech(np.zeros(512, dtype=np.float32)))

    def test_loud_signal_is_speech(self):
        self.assertTrue(self.vad.is_speech(np.ones(512, dtype=np.float32) * 0.8))

    def test_rms_db_silence(self):
        self.assertLess(self.vad.rms_db(np.zeros(512, dtype=np.float32)), -100.0)

    def test_rms_db_full_scale(self):
        self.assertAlmostEqual(self.vad.rms_db(np.ones(512, dtype=np.float32)), 0.0, delta=1.0)

    def test_custom_threshold(self):
        from saltybot_audio_pipeline.audio_utils import EnergyVAD
        vad_strict = EnergyVAD(threshold_db=-10.0)
        mid = np.ones(512, dtype=np.float32) * 0.1
        self.assertFalse(vad_strict.is_speech(mid))
        self.assertTrue(self.vad.is_speech(mid))


class TestPCMConversion(unittest.TestCase):
    def setUp(self):
        from saltybot_audio_pipeline.audio_utils import pcm16_to_float32, float32_to_pcm16
        self.to_f32 = pcm16_to_float32
        self.to_pcm = float32_to_pcm16

    def test_roundtrip(self):
        original = np.array([0.0, 0.5, -0.5, 1.0, -1.0], dtype=np.float32)
        restored = self.to_f32(self.to_pcm(original))
        np.testing.assert_allclose(original, restored, atol=1.5e-4)

    def test_zero_samples(self):
        zeros = np.zeros(100, dtype=np.float32)
        np.testing.assert_allclose(self.to_f32(self.to_pcm(zeros)), 0.0, atol=1e-4)

    def test_clipping(self):
        overflow = np.array([2.0, -2.0], dtype=np.float32)
        restored = self.to_f32(self.to_pcm(overflow))
        self.assertTrue(all(abs(v) <= 1.0 + 1e-4 for v in restored))

    def test_list_input(self):
        self.assertIsInstance(self.to_pcm([0.1, -0.1, 0.5]), bytes)

    def test_pcm_length(self):
        self.assertEqual(len(self.to_pcm(np.zeros(256, dtype=np.float32))), 256 * 2)


class TestAudioBuffer(unittest.TestCase):
    def setUp(self):
        from saltybot_audio_pipeline.audio_utils import AudioBuffer
        self.buf = AudioBuffer(capacity_s=1.0, sample_rate=16000)

    def test_push_and_size(self):
        self.buf.push(np.zeros(512, dtype=np.float32))
        self.assertEqual(self.buf.size(), 512)

    def test_extract_all(self):
        samples = np.ones(512, dtype=np.float32) * 0.5
        self.buf.push(samples)
        np.testing.assert_allclose(self.buf.extract(), 0.5)

    def test_extract_duration(self):
        self.buf.push(np.zeros(16000, dtype=np.float32))
        self.assertEqual(len(self.buf.extract(duration_s=0.5)), 8000)

    def test_clear(self):
        self.buf.push(np.zeros(512, dtype=np.float32))
        self.buf.clear()
        self.assertEqual(self.buf.size(), 0)

    def test_capacity_limit(self):
        self.buf.push(np.zeros(32000, dtype=np.float32))
        self.assertLessEqual(self.buf.size(), 16000)

    def test_thread_safety(self):
        errors = []
        def push_loop():
            for _ in range(50):
                try:
                    self.buf.push(np.zeros(100, dtype=np.float32))
                except Exception as e:
                    errors.append(e)
        threads = [threading.Thread(target=push_loop) for _ in range(5)]
        for t in threads: t.start()
        for t in threads: t.join()
        self.assertEqual(errors, [])


class TestUtteranceSegmenter(unittest.TestCase):
    def setUp(self):
        from saltybot_audio_pipeline.audio_utils import EnergyVAD, UtteranceSegmenter
        vad = EnergyVAD(threshold_db=-35.0)
        self.seg = UtteranceSegmenter(vad, silence_duration_s=0.1, min_duration_s=0.05, sample_rate=16000)

    def test_silence_produces_no_utterance(self):
        self.assertEqual(self.seg.push(np.zeros(1600, dtype=np.float32)), [])

    def test_speech_then_silence_produces_utterance(self):
        self.seg.push(np.ones(3200, dtype=np.float32) * 0.5)
        completed = self.seg.push(np.zeros(4000, dtype=np.float32))
        self.assertEqual(len(completed), 1)
        _, duration = completed[0]
        self.assertGreater(duration, 0.05)

    def test_reset_clears_buffer(self):
        self.seg.push(np.ones(3200, dtype=np.float32) * 0.5)
        self.seg.reset()
        self.assertEqual(self.seg.push(np.zeros(4000, dtype=np.float32)), [])


class TestResampleAudio(unittest.TestCase):
    def test_same_rate_is_identity(self):
        from saltybot_audio_pipeline.audio_utils import resample_audio
        s = np.ones(1000, dtype=np.float32) * 0.5
        np.testing.assert_array_equal(resample_audio(s, 16000, 16000), s)

    def test_upsample_increases_length(self):
        from saltybot_audio_pipeline.audio_utils import resample_audio
        self.assertGreater(len(resample_audio(np.ones(1000), 16000, 22050)), 1000)

    def test_downsample_decreases_length(self):
        from saltybot_audio_pipeline.audio_utils import resample_audio
        self.assertLess(len(resample_audio(np.ones(22050), 22050, 16000)), 22050)


class TestVoskSTT(unittest.TestCase):
    def test_init_bad_path_load_returns_false(self):
        from saltybot_audio_pipeline.audio_utils import VoskSTT
        vosk = VoskSTT(model_path="/nonexistent/path", sample_rate=16000)
        self.assertFalse(vosk.load())

    def test_transcribe_without_load_returns_empty(self):
        from saltybot_audio_pipeline.audio_utils import VoskSTT
        vosk = VoskSTT(model_path="/nonexistent/path", sample_rate=16000)
        self.assertEqual(vosk.transcribe(np.zeros(1600, dtype=np.float32)), "")


class TestJabraAudioDevice(unittest.TestCase):
    def test_read_without_open_returns_none(self):
        from saltybot_audio_pipeline.audio_pipeline_node import JabraAudioDevice
        self.assertIsNone(JabraAudioDevice().read_chunk(512))

    def test_write_without_open_returns_false(self):
        from saltybot_audio_pipeline.audio_pipeline_node import JabraAudioDevice
        self.assertFalse(JabraAudioDevice().write_chunk(b"\x00" * 1024))

    def test_close_without_open_is_safe(self):
        from saltybot_audio_pipeline.audio_pipeline_node import JabraAudioDevice
        JabraAudioDevice().close()  # Must not raise


class TestAudioMetrics(unittest.TestCase):
    def test_default_values(self):
        from saltybot_audio_pipeline.audio_pipeline_node import AudioMetrics
        m = AudioMetrics()
        self.assertEqual(m.wake_to_stt_ms, 0.0)
        self.assertEqual(m.transcribed_text, "")
        self.assertEqual(m.speaker_id, "unknown")

    def test_asdict(self):
        from dataclasses import asdict
        from saltybot_audio_pipeline.audio_pipeline_node import AudioMetrics
        d = asdict(AudioMetrics(transcribed_text="hello"))
        self.assertEqual(d["transcribed_text"], "hello")
        self.assertIn("wake_to_stt_ms", d)


class TestAudioState(unittest.TestCase):
    def test_state_values(self):
        from saltybot_audio_pipeline.audio_pipeline_node import AudioState
        self.assertEqual(AudioState.IDLE.value, "idle")
        self.assertEqual(AudioState.LISTENING.value, "listening")
        self.assertEqual(AudioState.WAKE_WORD_DETECTED.value, "wake_detected")
        self.assertEqual(AudioState.PROCESSING.value, "processing")
        self.assertEqual(AudioState.SPEAKING.value, "speaking")
        self.assertEqual(AudioState.ERROR.value, "error")

    def test_all_six_states(self):
        from saltybot_audio_pipeline.audio_pipeline_node import AudioState
        self.assertEqual(len(list(AudioState)), 6)


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