#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#

"""Unit tests for KrispVivaVadAnalyzer."""

import asyncio
import os
import sys
import tempfile
import unittest
from unittest.mock import MagicMock, patch

import numpy as np

# Mock krisp_audio module BEFORE any pipecat imports
# This allows tests to run without krisp_audio installed
mock_krisp_audio = MagicMock()
mock_krisp_audio.SamplingRate.Sr8000Hz = 8000
mock_krisp_audio.SamplingRate.Sr16000Hz = 16000
mock_krisp_audio.SamplingRate.Sr24000Hz = 24000
mock_krisp_audio.SamplingRate.Sr32000Hz = 32000
mock_krisp_audio.SamplingRate.Sr44100Hz = 44100
mock_krisp_audio.SamplingRate.Sr48000Hz = 48000
mock_krisp_audio.FrameDuration.Fd10ms = "10ms"
mock_krisp_audio.FrameDuration.Fd15ms = "15ms"
mock_krisp_audio.FrameDuration.Fd20ms = "20ms"
mock_krisp_audio.FrameDuration.Fd30ms = "30ms"
mock_krisp_audio.FrameDuration.Fd32ms = "32ms"

# Install the mock in sys.modules before importing
sys.modules["krisp_audio"] = mock_krisp_audio

# Mock pipecat_ai_krisp package
mock_pipecat_krisp = MagicMock()
sys.modules["pipecat_ai_krisp"] = mock_pipecat_krisp
sys.modules["pipecat_ai_krisp.audio"] = MagicMock()
sys.modules["pipecat_ai_krisp.audio.krisp_processor"] = MagicMock()

# Now we can safely import. The version patch is scoped to just the import so
# it doesn't leak across the test session and corrupt importlib.metadata.version
# for other tests (e.g. transformers' import-time dependency checks).
with patch("importlib.metadata.version", return_value="0.0.0-dev"):
    from pipecat.audio.vad.krisp_viva_vad import KrispVivaVadAnalyzer
    from pipecat.audio.vad.vad_analyzer import VADParams


class TestKrispVivaVadAnalyzer(unittest.TestCase):
    """Test suite for KrispVivaVadAnalyzer."""

    def setUp(self):
        """Set up test fixtures before each test method."""
        # Create a temporary .kef model file for testing
        self.temp_model_file = tempfile.NamedTemporaryFile(suffix=".kef", delete=False)
        self.temp_model_file.write(b"dummy model data")
        self.temp_model_file.close()
        self.model_path = self.temp_model_file.name

        # Use the global mock_krisp_audio that was set up before imports
        self.mock_krisp_audio = mock_krisp_audio

        # Reset all mocks to clear call counts from previous tests
        self.mock_krisp_audio.reset_mock()
        self.mock_krisp_audio.ModelInfo.reset_mock()
        self.mock_krisp_audio.VadSessionConfig.reset_mock()
        self.mock_krisp_audio.VadFloat.reset_mock()

        # Mock ModelInfo
        self.mock_model_info = MagicMock()
        self.mock_krisp_audio.ModelInfo.return_value = self.mock_model_info

        # Mock VadSessionConfig
        self.mock_vad_cfg = MagicMock()
        self.mock_krisp_audio.VadSessionConfig.return_value = self.mock_vad_cfg

        # Mock VAD session
        self.mock_session = MagicMock()
        self.mock_session.process = MagicMock(return_value=0.75)  # Return voice probability
        self.mock_krisp_audio.VadFloat.create.return_value = self.mock_session

        # Patch krisp_audio in the module
        self.krisp_audio_patch = patch(
            "pipecat.audio.vad.krisp_viva_vad.krisp_audio", self.mock_krisp_audio
        )
        self.krisp_audio_patch.start()

        # Patch KrispVivaSDKManager
        self.sdk_manager_patcher = patch("pipecat.audio.vad.krisp_viva_vad.KrispVivaSDKManager")
        self.mock_sdk_manager = self.sdk_manager_patcher.start()
        self.mock_sdk_manager.acquire = MagicMock()
        self.mock_sdk_manager.release = MagicMock()

    def tearDown(self):
        """Clean up test fixtures after each test method."""
        # Stop all patchers
        self.krisp_audio_patch.stop()
        self.sdk_manager_patcher.stop()

        # Remove temporary model file
        if os.path.exists(self.model_path):
            os.unlink(self.model_path)

    def test_initialization_with_model_path(self):
        """Test analyzer initialization with explicit model path."""
        analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)

        # Verify SDK was acquired during initialization
        self.mock_sdk_manager.acquire.assert_called_once()

        # Verify analyzer attributes
        self.assertEqual(analyzer._model_path, self.model_path)
        self.assertEqual(analyzer._frame_duration_ms, 10)  # Default frame duration
        self.assertIsNone(analyzer._session)  # Session created in set_sample_rate

    def test_initialization_with_env_variable(self):
        """Test analyzer initialization using KRISP_VIVA_VAD_MODEL_PATH environment variable."""
        with patch.dict(os.environ, {"KRISP_VIVA_VAD_MODEL_PATH": self.model_path}):
            analyzer = KrispVivaVadAnalyzer()

            self.mock_sdk_manager.acquire.assert_called_once()
            self.assertEqual(analyzer._model_path, self.model_path)

    def test_initialization_without_model_path(self):
        """Test analyzer initialization fails without model path."""
        with patch.dict(os.environ, {}, clear=True):
            with self.assertRaises(ValueError) as context:
                KrispVivaVadAnalyzer()

            self.assertIn("Model path", str(context.exception))
            # acquire() is not called because exception is raised before it
            self.mock_sdk_manager.acquire.assert_not_called()
            # release() is called in the initialization exception handler
            self.assertGreaterEqual(self.mock_sdk_manager.release.call_count, 1)

    def test_initialization_with_invalid_extension(self):
        """Test analyzer initialization fails with non-.kef file."""
        with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp:
            tmp.write(b"dummy")
            tmp_path = tmp.name

        try:
            with self.assertRaises(Exception) as context:
                KrispVivaVadAnalyzer(model_path=tmp_path)

            self.assertIn(".kef extension", str(context.exception))
            # acquire() is not called because exception is raised before it
            self.mock_sdk_manager.acquire.assert_not_called()
            # release() is called in the initialization exception handler
            self.assertGreaterEqual(self.mock_sdk_manager.release.call_count, 1)
        finally:
            os.unlink(tmp_path)

    def test_initialization_with_nonexistent_file(self):
        """Test analyzer initialization fails with non-existent model file."""
        with self.assertRaises(FileNotFoundError):
            KrispVivaVadAnalyzer(model_path="/nonexistent/path/model.kef")

        # acquire() is not called because exception is raised before it
        self.mock_sdk_manager.acquire.assert_not_called()
        # release() is called in the initialization exception handler
        self.assertGreaterEqual(self.mock_sdk_manager.release.call_count, 1)

    def test_initialization_with_custom_frame_duration(self):
        """Test analyzer initialization with custom frame duration."""
        analyzer = KrispVivaVadAnalyzer(model_path=self.model_path, frame_duration=20)

        self.assertEqual(analyzer._frame_duration_ms, 20)

    def test_initialization_with_sample_rate(self):
        """Test analyzer initialization with sample rate."""
        analyzer = KrispVivaVadAnalyzer(
            model_path=self.model_path, sample_rate=16000, frame_duration=10
        )

        # Should calculate samples per frame
        self.assertEqual(analyzer._samples_per_frame, 160)  # 16000 * 10 / 1000

    def test_set_sample_rate_with_supported_rates(self):
        """Test setting sample rate with supported values."""
        analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)

        for sample_rate in [8000, 16000, 32000, 44100, 48000]:
            analyzer.set_sample_rate(sample_rate)

            # Verify session was created
            self.assertIsNotNone(analyzer._session)
            self.assertEqual(analyzer.sample_rate, sample_rate)

            # Verify samples per frame was calculated
            expected_samples = int((sample_rate * analyzer._frame_duration_ms) / 1000)
            self.assertEqual(analyzer._samples_per_frame, expected_samples)

            # Verify VadSessionConfig was created and configured
            self.mock_krisp_audio.VadSessionConfig.assert_called()
            self.assertEqual(self.mock_vad_cfg.modelInfo, self.mock_model_info)

    def test_set_sample_rate_with_unsupported_rate(self):
        """Test setting sample rate with unsupported value raises ValueError."""
        analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)

        with self.assertRaises(ValueError) as context:
            analyzer.set_sample_rate(12000)  # Unsupported sample rate

        self.assertIn("sample rate needs to be", str(context.exception))

    def test_num_frames_required_with_session(self):
        """Test num_frames_required when session is created."""
        analyzer = KrispVivaVadAnalyzer(
            model_path=self.model_path, sample_rate=16000, frame_duration=10
        )
        analyzer.set_sample_rate(16000)

        # Should return samples per frame from session
        frames_required = analyzer.num_frames_required()
        self.assertEqual(frames_required, 160)  # 16000 * 10 / 1000

    def test_num_frames_required_without_session(self):
        """Test num_frames_required when session is not created yet."""
        analyzer = KrispVivaVadAnalyzer(
            model_path=self.model_path, sample_rate=16000, frame_duration=10
        )

        # Should calculate from sample_rate
        frames_required = analyzer.num_frames_required()
        self.assertEqual(frames_required, 160)

    def test_num_frames_required_with_different_sample_rates(self):
        """Test num_frames_required with different sample rates."""
        test_cases = [
            (8000, 10, 80),  # 8kHz @ 10ms = 80 samples
            (16000, 10, 160),  # 16kHz @ 10ms = 160 samples
            (16000, 20, 320),  # 16kHz @ 20ms = 320 samples
            (32000, 10, 320),  # 32kHz @ 10ms = 320 samples
            (44100, 10, 441),  # 44.1kHz @ 10ms = 441 samples
            (48000, 10, 480),  # 48kHz @ 10ms = 480 samples
        ]

        for sample_rate, frame_duration, expected_frames in test_cases:
            analyzer = KrispVivaVadAnalyzer(
                model_path=self.model_path,
                sample_rate=sample_rate,
                frame_duration=frame_duration,
            )
            analyzer.set_sample_rate(sample_rate)

            frames_required = analyzer.num_frames_required()
            self.assertEqual(
                frames_required,
                expected_frames,
                f"Failed for {sample_rate}Hz @ {frame_duration}ms",
            )

    def test_num_frames_required_fallback(self):
        """Test num_frames_required fallback when sample rate not set."""
        analyzer = KrispVivaVadAnalyzer(model_path=self.model_path, frame_duration=10)

        # Should use default fallback (16kHz)
        frames_required = analyzer.num_frames_required()
        self.assertEqual(frames_required, 160)  # 16000 * 10 / 1000

    def test_voice_confidence_with_valid_buffer(self):
        """Test voice_confidence with valid audio buffer."""
        analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)
        analyzer.set_sample_rate(16000)

        # Create audio buffer for one frame (160 samples = 320 bytes)
        samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16)
        audio_buffer = samples.tobytes()

        confidence = analyzer.voice_confidence(audio_buffer)

        # Verify confidence is returned
        self.assertIsInstance(confidence, float)
        self.assertEqual(confidence, 0.75)  # Mock returns 0.75

        # Verify session.process was called
        self.mock_session.process.assert_called_once()

    def test_voice_confidence_without_session(self):
        """Test voice_confidence when session is not initialized."""
        analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)

        # Create audio buffer
        samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16)
        audio_buffer = samples.tobytes()

        # Should return 0.0 and log warning
        confidence = analyzer.voice_confidence(audio_buffer)
        self.assertEqual(confidence, 0.0)

        # Verify session.process was NOT called
        self.mock_session.process.assert_not_called()

    def test_voice_confidence_error_handling(self):
        """Test voice_confidence handles processing errors gracefully."""
        analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)
        analyzer.set_sample_rate(16000)

        # Make session.process raise an exception
        self.mock_session.process.side_effect = Exception("Processing error")

        # Create audio buffer
        samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16)
        audio_buffer = samples.tobytes()

        # Should return 0.0 on error
        confidence = analyzer.voice_confidence(audio_buffer)
        self.assertEqual(confidence, 0.0)

    def test_voice_confidence_audio_conversion(self):
        """Test voice_confidence properly converts audio format."""
        analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)
        analyzer.set_sample_rate(16000)

        # Create deterministic audio buffer
        samples = np.array([1000, -2000, 3000, -4000], dtype=np.int16)
        audio_buffer = samples.tobytes()

        analyzer.voice_confidence(audio_buffer)

        # Verify process was called with float32 array
        call_args = self.mock_session.process.call_args[0][0]
        self.assertIsInstance(call_args, np.ndarray)
        self.assertEqual(call_args.dtype, np.float32)

        # Verify normalization (int16 to float32)
        expected_float = samples.astype(np.float32) / 32768.0
        np.testing.assert_array_almost_equal(call_args, expected_float)

    def test_voice_confidence_different_buffer_sizes(self):
        """Test voice_confidence with different buffer sizes."""
        analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)
        analyzer.set_sample_rate(16000)

        test_sizes = [80, 160, 320, 480]  # Different numbers of samples

        for size in test_sizes:
            samples = np.random.randint(-32768, 32767, size=size, dtype=np.int16)
            audio_buffer = samples.tobytes()

            confidence = analyzer.voice_confidence(audio_buffer)

            # Should always return a float
            self.assertIsInstance(confidence, float)
            self.assertGreaterEqual(confidence, 0.0)
            self.assertLessEqual(confidence, 1.0)

    def test_cleanup(self):
        """Test that cleanup releases resources explicitly."""
        analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)
        analyzer.set_sample_rate(16000)

        # Reset mock to track calls
        self.mock_sdk_manager.release.reset_mock()

        asyncio.run(analyzer.cleanup())

        # Verify SDK was released and session was cleared
        self.mock_sdk_manager.release.assert_called_once()
        self.assertIsNone(analyzer._session)

    def test_initialization_with_vad_params(self):
        """Test analyzer initialization with VAD parameters."""
        params = VADParams(confidence=0.8, start_secs=0.3, stop_secs=0.9)
        analyzer = KrispVivaVadAnalyzer(
            model_path=self.model_path, sample_rate=16000, params=params
        )

        self.assertEqual(analyzer.params.confidence, 0.8)
        self.assertEqual(analyzer.params.start_secs, 0.3)
        self.assertEqual(analyzer.params.stop_secs, 0.9)

    def test_set_sample_rate_creates_session(self):
        """Test that set_sample_rate creates a new session."""
        analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)

        # Initially no session
        self.assertIsNone(analyzer._session)

        # Set sample rate should create session
        analyzer.set_sample_rate(16000)

        # Verify session was created
        self.assertIsNotNone(analyzer._session)
        self.mock_krisp_audio.VadFloat.create.assert_called_once()

    def test_set_sample_rate_replaces_session(self):
        """Test that set_sample_rate replaces existing session."""
        analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)

        # Create first session
        analyzer.set_sample_rate(16000)
        session1 = analyzer._session
        self.assertIsNotNone(session1)

        # Reset mock to track new calls
        self.mock_krisp_audio.VadFloat.create.reset_mock()

        # Set different sample rate should create new session
        analyzer.set_sample_rate(48000)
        session2 = analyzer._session
        self.assertIsNotNone(session2)

        # Verify new session was created
        self.mock_krisp_audio.VadFloat.create.assert_called_once()

    def test_session_configuration(self):
        """Test that session is configured correctly."""
        analyzer = KrispVivaVadAnalyzer(model_path=self.model_path, frame_duration=15)
        analyzer.set_sample_rate(16000)

        # Verify VadSessionConfig was configured
        self.assertEqual(self.mock_vad_cfg.modelInfo, self.mock_model_info)
        self.assertEqual(self.mock_vad_cfg.inputFrameDuration, "15ms")

        # Verify sample rate was set
        from pipecat.audio.krisp_instance import int_to_krisp_sample_rate

        expected_sample_rate = int_to_krisp_sample_rate(16000)
        self.assertEqual(self.mock_vad_cfg.inputSampleRate, expected_sample_rate)

    def test_multiple_sample_rate_changes(self):
        """Test multiple sample rate changes."""
        analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)

        sample_rates = [8000, 16000, 32000, 48000]

        for sample_rate in sample_rates:
            analyzer.set_sample_rate(sample_rate)
            self.assertEqual(analyzer.sample_rate, sample_rate)

            # Verify num_frames_required is correct
            expected_frames = int((sample_rate * 10) / 1000)
            self.assertEqual(analyzer.num_frames_required(), expected_frames)


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