"""Tests for audio activity trigger provider."""

from __future__ import annotations

import time
from typing import cast

import numpy as np
import pytest
from _pytest.python_api import ApproxBase

from noteflow.infrastructure.audio import RmsLevelProvider
from noteflow.infrastructure.triggers.audio_activity import (
    AudioActivityProvider,
    AudioActivitySettings,
)


class _ApproxCallable:
    """Protocol for pytest.approx with explicit parameter types.

    Used to cast pytest.approx to avoid basedpyright reportUnknownMemberType
    errors caused by pytest.approx having partially unknown parameter types.
    """

    def __call__(
        self,
        expected: float,
        *,
        rel: float | None = None,
        abs: float | None = None,
        nan_ok: bool = False,
    ) -> ApproxBase:
        """Call signature matching pytest.approx for float values."""
        ...


def _approx_float(
    expected: float,
    *,
    rel: float | None = None,
    abs: float | None = None,
) -> ApproxBase:
    """Type-safe wrapper for pytest.approx with float values.

    Cast required: pytest.approx has untyped parameters (expected, rel, abs) in its
    signature. The return type ApproxBase is correctly typed, but pyright reports
    the function type as partially unknown due to the untyped parameters.
    """
    _approx: _ApproxCallable = cast(_ApproxCallable, pytest.approx)
    return _approx(expected, rel=rel, abs=abs)


def _settings(
    *,
    enabled: bool = True,
    threshold_db: float = -20.0,
    window_seconds: float = 10.0,
    min_active_ratio: float = 0.6,
    min_samples: int = 3,
    max_history: int = 10,
    weight: float = 0.3,
) -> AudioActivitySettings:
    return AudioActivitySettings(
        enabled=enabled,
        threshold_db=threshold_db,
        window_seconds=window_seconds,
        min_active_ratio=min_active_ratio,
        min_samples=min_samples,
        max_history=max_history,
        weight=weight,
    )


def test_audio_activity_settings_validation() -> None:
    """Settings should reject min_samples greater than max_history."""
    with pytest.raises(ValueError, match="min_samples"):
        AudioActivitySettings(
            enabled=True,
            threshold_db=-20.0,
            window_seconds=5.0,
            min_active_ratio=0.5,
            min_samples=11,
            max_history=10,
            weight=0.3,
        )


def test_audio_activity_provider_disabled_ignores_updates() -> None:
    """Disabled provider should not emit signals."""
    provider = AudioActivityProvider(RmsLevelProvider(), _settings(enabled=False))
    frames = np.ones(10, dtype=np.float32)

    provider.update(frames, timestamp=1.0)

    assert provider.get_signal() is None, "disabled provider should not emit signal"


def test_audio_activity_provider_emits_signal(monkeypatch: pytest.MonkeyPatch) -> None:
    """Provider emits a signal when sustained activity passes ratio threshold."""
    provider = AudioActivityProvider(RmsLevelProvider(), _settings())
    active = np.ones(10, dtype=np.float32)
    inactive = np.zeros(10, dtype=np.float32)

    provider.update(active, timestamp=1.0)
    provider.update(active, timestamp=2.0)
    provider.update(inactive, timestamp=3.0)

    monkeypatch.setattr(time, "monotonic", lambda: 4.0)
    signal = provider.get_signal()

    assert signal is not None, "provider should emit signal after sustained activity"
    assert signal.weight == _approx_float(0.3), "signal weight should match configured weight"


def test_audio_activity_provider_window_excludes_old_samples(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Samples outside the window should not contribute to activity ratio."""
    provider = AudioActivityProvider(RmsLevelProvider(), _settings(window_seconds=2.0))
    active = np.ones(10, dtype=np.float32)

    provider.update(active, timestamp=1.0)
    provider.update(active, timestamp=2.0)
    provider.update(active, timestamp=3.0)

    monkeypatch.setattr(time, "monotonic", lambda: 10.0)
    assert provider.get_signal() is None, "old samples outside window should not trigger signal"


def test_audio_activity_provider_source_property() -> None:
    """Provider source should be AUDIO_ACTIVITY."""
    from noteflow.domain.triggers.entities import TriggerSource

    provider = AudioActivityProvider(RmsLevelProvider(), _settings())
    assert provider.source == TriggerSource.AUDIO_ACTIVITY, "source should be AUDIO_ACTIVITY"


def test_audio_activity_provider_max_weight_property() -> None:
    """Provider max_weight should reflect configured weight."""
    provider = AudioActivityProvider(RmsLevelProvider(), _settings(weight=0.5))
    assert provider.max_weight == _approx_float(0.5), "max_weight should match configured weight"


def test_audio_activity_provider_is_enabled_reflects_settings() -> None:
    """is_enabled should reflect settings.enabled."""
    enabled_provider = AudioActivityProvider(RmsLevelProvider(), _settings(enabled=True))
    disabled_provider = AudioActivityProvider(RmsLevelProvider(), _settings(enabled=False))

    assert enabled_provider.is_enabled() is True, "provider with enabled=True should be enabled"
    assert disabled_provider.is_enabled() is False, "provider with enabled=False should be disabled"


def test_audio_activity_provider_clear_history() -> None:
    """clear_history should reset the activity history."""
    provider = AudioActivityProvider(RmsLevelProvider(), _settings())
    active = np.ones(10, dtype=np.float32)

    provider.update(active, timestamp=1.0)
    provider.update(active, timestamp=2.0)
    provider.update(active, timestamp=3.0)

    provider.clear_history()

    # After clearing, signal should be None due to insufficient samples
    assert provider.get_signal() is None, "cleared history should result in no signal"


def test_audio_activity_provider_insufficient_samples() -> None:
    """Provider should return None when history has fewer than min_samples."""
    provider = AudioActivityProvider(RmsLevelProvider(), _settings(min_samples=5))
    active = np.ones(10, dtype=np.float32)

    # Add only 3 samples (less than min_samples=5)
    provider.update(active, timestamp=1.0)
    provider.update(active, timestamp=2.0)
    provider.update(active, timestamp=3.0)

    assert provider.get_signal() is None, "fewer than min_samples should not trigger signal"


def test_audio_activity_provider_below_activity_ratio() -> None:
    """Provider should return None when active ratio < min_active_ratio."""
    provider = AudioActivityProvider(RmsLevelProvider(), _settings(min_active_ratio=0.7))
    active = np.ones(10, dtype=np.float32)
    inactive = np.zeros(10, dtype=np.float32)

    # Add 3 active, 7 inactive = 30% active ratio (below 70% threshold)
    provider.update(active, timestamp=1.0)
    provider.update(active, timestamp=2.0)
    provider.update(active, timestamp=3.0)
    provider.update(inactive, timestamp=4.0)
    provider.update(inactive, timestamp=5.0)
    provider.update(inactive, timestamp=6.0)
    provider.update(inactive, timestamp=7.0)
    provider.update(inactive, timestamp=8.0)
    provider.update(inactive, timestamp=9.0)
    provider.update(inactive, timestamp=10.0)

    assert provider.get_signal() is None, "activity ratio below threshold should not trigger signal"


def test_audio_activity_provider_boundary_activity_ratio(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Provider should emit signal when ratio exactly equals min_active_ratio."""
    provider = AudioActivityProvider(
        RmsLevelProvider(),
        _settings(min_active_ratio=0.6, min_samples=5, max_history=10),
    )
    active = np.ones(10, dtype=np.float32)
    inactive = np.zeros(10, dtype=np.float32)

    # Add 6 active, 4 inactive = 60% active ratio (exactly at threshold)
    provider.update(active, timestamp=1.0)
    provider.update(active, timestamp=2.0)
    provider.update(active, timestamp=3.0)
    provider.update(active, timestamp=4.0)
    provider.update(active, timestamp=5.0)
    provider.update(active, timestamp=6.0)
    provider.update(inactive, timestamp=7.0)
    provider.update(inactive, timestamp=8.0)
    provider.update(inactive, timestamp=9.0)
    provider.update(inactive, timestamp=10.0)

    monkeypatch.setattr(time, "monotonic", lambda: 11.0)
    signal = provider.get_signal()

    assert signal is not None, "boundary ratio equal to threshold should trigger signal"
    assert signal.weight == _approx_float(0.3), "signal weight should match configured weight"
