from __future__ import annotations

import queue
from dataclasses import dataclass
from typing import Iterable


def load_sounddevice():
    try:
        import sounddevice as sd  # type: ignore
    except ImportError as exc:
        raise SystemExit(
            "The 'sounddevice' package is required.\n"
            "Install it with: python -m pip install sounddevice"
        ) from exc
    return sd


def parse_channel_list(text: str) -> list[int]:
    """Parse user-facing one-based channel numbers into zero-based indices."""
    cleaned = text.replace(";", ",").replace(" ", ",")
    parts = [part for part in cleaned.split(",") if part]
    if not parts:
        raise ValueError("no channels were specified")

    channels_one_based: list[int] = []
    for part in parts:
        try:
            channel = int(part)
        except ValueError as exc:
            raise ValueError(f"invalid channel number '{part}'") from exc
        if channel < 1:
            raise ValueError("channel numbers must be one-based and >= 1")
        channels_one_based.append(channel)

    channels_zero_based = [channel - 1 for channel in channels_one_based]
    if len(set(channels_zero_based)) != len(channels_zero_based):
        raise ValueError("duplicate channels are not allowed")
    return channels_zero_based


def parse_channel(text: str) -> int:
    channels = parse_channel_list(text)
    if len(channels) != 1:
        raise ValueError("expected exactly one channel")
    return channels[0]


def format_channel_list(channels_zero_based: Iterable[int]) -> str:
    return ", ".join(str(channel + 1) for channel in channels_zero_based)


@dataclass(frozen=True)
class InputDeviceEntry:
    index: int
    info: dict
    label: str


class SoundDeviceManager:
    def __init__(self, sd):
        self.sd = sd

    def input_devices(self) -> list[InputDeviceEntry]:
        devices = self.sd.query_devices()
        hostapis = self.sd.query_hostapis()
        entries: list[InputDeviceEntry] = []
        for index, device in enumerate(devices):
            max_inputs = int(device.get("max_input_channels", 0))
            if max_inputs <= 0:
                continue
            hostapi_name = hostapis[int(device["hostapi"])]["name"]
            label = (
                f"[{index}] {device['name']} | {hostapi_name} | "
                f"{max_inputs} in | {float(device['default_samplerate']):g} Hz"
            )
            entries.append(InputDeviceEntry(index=index, info=device, label=label))
        return entries

    def print_input_devices(self) -> list[InputDeviceEntry]:
        entries = self.input_devices()
        print("Available input devices:")
        for entry in entries:
            max_inputs = int(entry.info.get("max_input_channels", 0))
            default_sr = float(entry.info["default_samplerate"])
            hostapi_name = entry.label.split(" | ")[1]
            print(
                f"  [{entry.index:2d}] {entry.info['name']} | {hostapi_name} | "
                f"inputs: {max_inputs} | default fs: {default_sr:g} Hz"
            )
        if not entries:
            raise SystemExit("No input-capable sounddevice devices were found.")
        return entries

    def prompt_input_device_index(self, entries: list[InputDeviceEntry] | None = None) -> int:
        entries = entries if entries is not None else self.input_devices()
        valid_indices = {entry.index for entry in entries}
        while True:
            text = input("Select input device index: ").strip()
            try:
                device_index = int(text)
            except ValueError:
                print("Please enter a numeric device index.")
                continue
            if device_index not in valid_indices:
                print("That device index is not an input-capable device.")
                continue
            return device_index

    def device_info(self, device_index: int) -> dict:
        try:
            device = self.sd.query_devices(device_index)
        except Exception as exc:
            raise SystemExit(f"Invalid device index {device_index}: {exc}") from exc
        if int(device.get("max_input_channels", 0)) <= 0:
            raise SystemExit(f"Device {device_index} has no input channels.")
        return device

    def validate_channels(self, channels_zero_based: list[int], max_input_channels: int) -> None:
        if not channels_zero_based:
            raise ValueError("at least one channel must be selected")
        invalid = [channel + 1 for channel in channels_zero_based if channel >= max_input_channels]
        if invalid:
            raise ValueError(
                f"channel(s) {invalid} exceed device input count {max_input_channels}"
            )

    def prompt_channels(self, max_input_channels: int) -> list[int]:
        while True:
            text = input(
                "Select input channel(s), one-based, comma-separated "
                f"(1-{max_input_channels}): "
            ).strip()
            try:
                channels = parse_channel_list(text)
                self.validate_channels(channels, max_input_channels)
            except ValueError as exc:
                print(f"Invalid channel selection: {exc}")
                continue
            return channels


class QueuedInputStream:
    """Object-oriented queue-backed sounddevice input stream."""

    def __init__(
        self,
        sd,
        device_index: int,
        channels_zero_based: list[int],
        samplerate: float | None,
        blocksize: int,
        max_queue_blocks: int = 32,
    ):
        self.sd = sd
        self.device_index = device_index
        self.channels_zero_based = channels_zero_based
        self.samplerate = samplerate
        self.blocksize = blocksize
        self.queue: queue.Queue = queue.Queue(maxsize=max_queue_blocks)
        self.stream = None

    @property
    def stream_channel_count(self) -> int:
        return max(self.channels_zero_based) + 1

    @property
    def actual_samplerate(self) -> float:
        if self.stream is None:
            raise RuntimeError("stream is not open")
        return float(self.stream.samplerate)

    def callback(self, indata, frames, time_info, status) -> None:
        try:
            self.queue.put_nowait((indata.copy(), status))
        except queue.Full:
            pass

    def start(self) -> None:
        kwargs = {
            "device": self.device_index,
            "channels": self.stream_channel_count,
            "samplerate": self.samplerate,
            "blocksize": self.blocksize,
            "dtype": "float32",
            "callback": self.callback,
        }
        if self.samplerate is None:
            del kwargs["samplerate"]
        self.stream = self.sd.InputStream(**kwargs)
        self.stream.start()

    def stop(self) -> None:
        if self.stream is not None:
            self.stream.stop()
            self.stream.close()
            self.stream = None

    def clear(self) -> None:
        while not self.queue.empty():
            try:
                self.queue.get_nowait()
            except queue.Empty:
                break

