"""App audio activity provider.

Detects audio activity from system output while whitelisted meeting apps are active.
This is a best-effort heuristic: it combines (a) system output activity and
(b) presence of whitelisted app windows to infer a likely meeting.
"""

from __future__ import annotations

import time
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Protocol, cast

from noteflow.config.constants import DEFAULT_SAMPLE_RATE
from noteflow.domain.triggers.entities import TriggerSignal, TriggerSource
from noteflow.infrastructure.audio.levels import RmsLevelProvider
from noteflow.infrastructure.logging import get_logger
from noteflow.infrastructure.triggers.audio_activity import (
    AudioActivityProvider,
    AudioActivitySettings,
)

if TYPE_CHECKING:
    import numpy as np
    from numpy.typing import NDArray

logger = get_logger(__name__)


class _InputStream(Protocol):
    def read(self, frames: int) -> tuple[NDArray[np.float32], bool]: ...
    def start(self) -> None: ...
    def stop(self) -> None: ...
    def close(self) -> None: ...


class _SoundDeviceDefault(Protocol):
    device: tuple[int | None, int | None]
    hostapi: int | None


class _SoundDeviceModule(Protocol):
    default: _SoundDeviceDefault

    def query_hostapis(
        self, index: int | None = None
    ) -> Sequence[Mapping[str, object]] | Mapping[str, object]: ...

    def query_devices(
        self, device: int | None = None, kind: str | None = None
    ) -> Sequence[Mapping[str, object]] | Mapping[str, object]: ...

    def InputStream(self, **kwargs: object) -> _InputStream: ...


@dataclass
class AppAudioSettings:
    """Configuration for app audio detection.

    Attributes:
        enabled: Whether app audio detection is enabled.
        threshold_db: Minimum dB level to consider as activity.
        window_seconds: Time window for sustained activity detection.
        min_active_ratio: Minimum ratio of active samples in window.
        min_samples: Minimum samples required before evaluation.
        max_history: Maximum samples retained in history.
        weight: Confidence weight contributed by this provider.
        meeting_apps: Set of app name substrings to match (lowercase).
        suppressed_apps: App substrings to ignore even if matched.
        sample_rate: Sample rate for system output capture.
        sample_duration_seconds: Duration of each sampling read.
        chunk_duration_seconds: Duration of sub-chunks for activity history updates.
    """

    enabled: bool
    threshold_db: float
    window_seconds: float
    min_active_ratio: float
    min_samples: int
    max_history: int
    weight: float
    meeting_apps: set[str] = field(default_factory=set)
    suppressed_apps: set[str] = field(default_factory=set)
    sample_rate: int = DEFAULT_SAMPLE_RATE
    sample_duration_seconds: float = 0.5
    chunk_duration_seconds: float = 0.1

    def __post_init__(self) -> None:
        self.meeting_apps = {app.lower() for app in self.meeting_apps}
        self.suppressed_apps = {app.lower() for app in self.suppressed_apps}


class _SystemOutputSampler:
    """Best-effort system output sampler using sounddevice."""

    def __init__(self, sample_rate: int, channels: int = 1) -> None:
        self._sample_rate = sample_rate
        self._channels = channels
        self._stream: _InputStream | None = None
        self._extra_settings = None
        self._device: int | None = None
        self._available: bool | None = None

    def _select_device(self) -> None:
        try:
            import sounddevice as sd
        except ImportError:
            self._mark_unavailable_with_warning(
                "sounddevice not available - app audio detection disabled"
            )
            return
        sd_typed = cast(_SoundDeviceModule, sd)
        # Default to output device and WASAPI loopback when available (Windows)
        try:
            default_output = sd_typed.default.device[1]
        except (TypeError, IndexError):
            default_output = None

        try:
            hostapi_index = sd_typed.default.hostapi
            hostapi_value = (
                sd_typed.query_hostapis(hostapi_index)
                if isinstance(hostapi_index, int)
                else None
            )
        except (OSError, RuntimeError):
            hostapi_value = None

        hostapi = hostapi_value if isinstance(hostapi_value, Mapping) else None

        if hostapi and hostapi.get("type") == "Windows WASAPI" and default_output is not None:
            # On WASAPI, loopback devices appear as separate input devices
            # Fall through to monitor/loopback device detection below
            pass

        # Fallback: look for monitor/loopback devices (Linux/PulseAudio)
        try:
            sd_typed = cast(_SoundDeviceModule, sd)
            devices_value = sd_typed.query_devices()
        except (OSError, RuntimeError):
            return self._mark_unavailable_with_warning(
                "Failed to query audio devices for app audio detection"
            )

        if isinstance(devices_value, Mapping):
            devices = [devices_value]
        else:
            devices = list(devices_value)

        for idx, dev in enumerate(devices):
            name = str(dev.get("name", "")).lower()
            max_channels = dev.get("max_input_channels", 0)
            if isinstance(max_channels, int):
                max_input_channels = max_channels
            elif isinstance(max_channels, float):
                max_input_channels = int(max_channels)
            else:
                max_input_channels = 0
            if max_input_channels <= 0:
                continue
            if "monitor" in name or "loopback" in name:
                return self._mark_device_available(idx)
        self._available = False
        logger.warning("No loopback audio device found - app audio detection disabled")

    def _mark_device_available(self, device_index: int) -> None:
        """Mark the device as available for audio capture.

        Args:
            device_index: Index of the audio device.
        """
        self._device = device_index
        self._available = True

    def _mark_unavailable_with_warning(self, message: str) -> None:
        """Mark device as unavailable and log a warning.

        Args:
            message: Warning message to log.
        """
        self._available = False
        logger.warning(message)

    def _ensure_stream(self) -> bool:
        if self._available is False:
            return False

        if self._available is None:
            self._select_device()
            if self._available is not None and not self._available:
                return False

        if self._stream is not None:
            return True

        try:
            import sounddevice as sd

            sd_typed = cast(_SoundDeviceModule, sd)
            stream = sd_typed.InputStream(
                device=self._device,
                channels=self._channels,
                samplerate=self._sample_rate,
                dtype="float32",
                extra_settings=self._extra_settings,
            )
            stream.start()
            self._stream = stream
            return True
        except (OSError, RuntimeError) as exc:
            logger.warning("Failed to start system output capture: %s", exc)
            self._stream = None
            self._available = False
            return False

    def read_frames(self, duration_seconds: float) -> NDArray[np.float32] | None:
        if not self._ensure_stream():
            return None

        if self._stream is None:
            return None

        frames = max(1, int(self._sample_rate * duration_seconds))
        try:
            data, _ = self._stream.read(frames)
        except (OSError, RuntimeError) as exc:
            logger.debug("System output read failed: %s", exc)
            return None

        return data.reshape(-1).astype("float32")

    def close(self) -> None:
        if self._stream is None:
            return
        try:
            self._stream.stop()
            self._stream.close()
        except (OSError, RuntimeError):
            logger.debug("Failed to close system output stream", exc_info=True)
        finally:
            self._stream = None


class AppAudioProvider:
    """Detect app audio activity from whitelisted meeting apps."""

    def __init__(self, settings: AppAudioSettings) -> None:
        self._settings = settings
        self._sampler = _SystemOutputSampler(sample_rate=settings.sample_rate)
        self._level_provider = RmsLevelProvider()
        self._audio_activity = AudioActivityProvider(
            self._level_provider,
            AudioActivitySettings(
                enabled=settings.enabled,
                threshold_db=settings.threshold_db,
                window_seconds=settings.window_seconds,
                min_active_ratio=settings.min_active_ratio,
                min_samples=settings.min_samples,
                max_history=settings.max_history,
                weight=settings.weight,
            ),
        )

    @property
    def source(self) -> TriggerSource:
        return TriggerSource.AUDIO_ACTIVITY

    @property
    def max_weight(self) -> float:
        return self._settings.weight

    def is_enabled(self) -> bool:
        return self._settings.enabled

    def get_signal(self) -> TriggerSignal | None:
        if not self.is_enabled():
            return None
        if not self._settings.meeting_apps:
            return None

        app_title = self._detect_meeting_app()
        if not app_title:
            return None

        frames = self._sampler.read_frames(self._settings.sample_duration_seconds)
        if frames is None or frames.size == 0:
            return None

        self._update_activity_history(frames)
        if self._audio_activity.get_signal() is None:
            return None

        return TriggerSignal(
            source=self.source,
            weight=self.max_weight,
            app_name=app_title,
        )

    def _update_activity_history(self, frames: NDArray[np.float32]) -> None:
        chunk_size = max(1, int(self._settings.sample_rate * self._settings.chunk_duration_seconds))
        now = time.monotonic()
        for offset in range(0, len(frames), chunk_size):
            chunk = frames[offset : offset + chunk_size]
            if chunk.size == 0:
                continue
            self._audio_activity.update(chunk, now)

    def _detect_meeting_app(self) -> str | None:
        try:
            import pywinctl
        except ImportError:
            return None

        titles: list[str] = []
        try:
            if hasattr(pywinctl, "getAllWindows"):
                windows = pywinctl.getAllWindows()
                titles = [w.title for w in windows if getattr(w, "title", None)]
            elif hasattr(pywinctl, "getAllTitles"):
                titles = [t for t in pywinctl.getAllTitles() if t]
        # INTENTIONAL BROAD HANDLER: Platform interop via pywinctl
        # - Window listing APIs vary by platform
        # - Must not crash detection if window enumeration fails
        except Exception as exc:
            logger.debug("Failed to list windows for app detection: %s", exc)
            return None

        for title in titles:
            title_lower = title.lower()
            if any(suppressed in title_lower for suppressed in self._settings.suppressed_apps):
                continue
            if any(app in title_lower for app in self._settings.meeting_apps):
                return title

        return None

    def close(self) -> None:
        """Release system audio resources."""
        self._sampler.close()
