from __future__ import annotations

import threading
from pathlib import Path
from typing import Iterable

import numpy as np
import sounddevice as sd
import soundfile as sf


def list_devices() -> list[dict]:
    """Return stable, serialisable metadata for every PortAudio device."""
    devices: list[dict] = []
    host_apis = sd.query_hostapis()
    for index, item in enumerate(sd.query_devices()):
        host_api_index = int(item["hostapi"])
        host_api_name = str(host_apis[host_api_index]["name"])
        devices.append(
            {
                "index": index,
                "name": str(item["name"]),
                "hostapi": host_api_name,
                "inputs": int(item["max_input_channels"]),
                "outputs": int(item["max_output_channels"]),
                "default_samplerate": int(float(item["default_samplerate"])),
            }
        )
    return devices


def _normalise_name(value: str) -> str:
    return " ".join((value or "").casefold().split())


def find_device_by_name(devices: Iterable[dict], name: str, *, direction: str) -> int:
    """Find a device after Windows/PortAudio reorders numeric indexes."""
    wanted = _normalise_name(name)
    if not wanted:
        return -1
    key = "inputs" if direction == "input" else "outputs"
    exact = [item for item in devices if int(item.get(key, 0)) > 0 and _normalise_name(str(item.get("name", ""))) == wanted]
    if exact:
        return int(exact[0]["index"])
    partial = [item for item in devices if int(item.get(key, 0)) > 0 and wanted in _normalise_name(str(item.get("name", "")))]
    return int(partial[0]["index"]) if partial else -1


def choose_default_input(devices: Iterable[dict]) -> int:
    candidates = [item for item in devices if int(item.get("inputs", 0)) > 0]
    if not candidates:
        return -1
    try:
        default_input, _ = sd.default.device
        if default_input is not None and int(default_input) >= 0:
            return int(default_input)
    except Exception:
        pass
    return int(candidates[0]["index"])


def choose_vb_cable_output(devices: Iterable[dict]) -> int:
    outputs = [item for item in devices if int(item.get("outputs", 0)) > 0]
    if not outputs:
        return -1
    preferred_tokens = (
        "cable input",
        "vb-audio virtual cable",
        "vb-cable",
        "cable-a input",
        "cable-b input",
    )
    for token in preferred_tokens:
        for item in outputs:
            if token in _normalise_name(str(item.get("name", ""))):
                return int(item["index"])
    return -1


def device_name(devices: Iterable[dict], index: int) -> str:
    for item in devices:
        if int(item.get("index", -1)) == int(index):
            return str(item.get("name", ""))
    return ""


class DirectVoiceEngine:
    def __init__(self, status_cb=None, level_cb=None):
        self.status_cb = status_cb or (lambda _x: None)
        self.level_cb = level_cb or (lambda _x: None)
        self.stream = None
        self.running = False
        self.muted = False
        self.lock = threading.RLock()
        self.samplerate = 0
        self.output_channels = 1

    def set_muted(self, value: bool):
        with self.lock:
            self.muted = bool(value)

    def start(
        self,
        input_device: int,
        output_device: int,
        samplerate: int = 48000,
        output_mode: str = "Stereo",
    ):
        self.stop()
        input_info = sd.query_devices(input_device, "input")
        output_info = sd.query_devices(output_device, "output")
        requested_channels = 2 if str(output_mode).casefold() == "stereo" else 1
        output_channels = max(1, min(requested_channels, int(output_info["max_output_channels"])))
        candidates = [
            int(samplerate),
            int(float(input_info["default_samplerate"])),
            int(float(output_info["default_samplerate"])),
            48000,
            44100,
            16000,
        ]
        rate = None
        last_error = None
        for candidate in dict.fromkeys(candidates):
            try:
                sd.check_input_settings(device=input_device, channels=1, samplerate=candidate, dtype="float32")
                sd.check_output_settings(
                    device=output_device,
                    channels=output_channels,
                    samplerate=candidate,
                    dtype="float32",
                )
                rate = candidate
                break
            except Exception as exc:
                last_error = exc
        if rate is None:
            raise RuntimeError(f"Немає сумісної частоти для вибраних пристроїв: {last_error}")

        def callback(indata, outdata, frames, _time, status):
            if status:
                self.status_cb(f"audio_status:{status}")
            with self.lock:
                muted = self.muted
            if muted:
                outdata.fill(0)
                self.level_cb(0.0)
                return
            mono = indata[:, :1]
            if outdata.shape[1] == 1:
                outdata[:, :1] = mono
            else:
                outdata[:] = np.repeat(mono, outdata.shape[1], axis=1)
            peak = float(np.max(np.abs(indata))) if frames else 0.0
            self.level_cb(float(min(1.0, peak)))

        self.stream = sd.Stream(
            device=(input_device, output_device),
            samplerate=rate,
            channels=(1, output_channels),
            dtype="float32",
            latency="low",
            callback=callback,
        )
        self.stream.start()
        self.running = True
        self.samplerate = int(rate)
        self.output_channels = int(output_channels)
        self.status_cb(f"started:{rate}:{output_channels}")

    def stop(self):
        stream = self.stream
        self.stream = None
        self.running = False
        if stream is not None:
            try:
                stream.stop()
            finally:
                stream.close()
            self.status_cb("stopped")


class PhraseRecorder:
    def __init__(self, input_device: int, samplerate: int = 48000):
        self.input_device = input_device
        self.samplerate = samplerate
        self.stream = None
        self.blocks: list[np.ndarray] = []
        self.lock = threading.Lock()

    def start(self):
        self.blocks = []
        info = sd.query_devices(self.input_device, "input")
        candidates = [self.samplerate, int(float(info["default_samplerate"])), 48000, 44100, 16000]
        rate = None
        last_error = None
        for candidate in dict.fromkeys(int(value) for value in candidates):
            try:
                sd.check_input_settings(
                    device=self.input_device,
                    channels=1,
                    samplerate=candidate,
                    dtype="float32",
                )
                rate = candidate
                break
            except Exception as exc:
                last_error = exc
        if rate is None:
            raise RuntimeError(f"Не вдалося відкрити мікрофон: {last_error}")
        self.samplerate = int(rate)

        def callback(indata, _frames, _time, _status):
            with self.lock:
                self.blocks.append(indata.copy())

        self.stream = sd.InputStream(
            device=self.input_device,
            samplerate=rate,
            channels=1,
            dtype="float32",
            callback=callback,
        )
        self.stream.start()

    def cancel(self) -> None:
        stream = self.stream
        self.stream = None
        if stream is not None:
            try:
                stream.stop()
            finally:
                stream.close()
        with self.lock:
            self.blocks = []

    def stop_to_wav(self, path: Path) -> float:
        stream = self.stream
        self.stream = None
        if stream is not None:
            stream.stop()
            stream.close()
        with self.lock:
            data = np.concatenate(self.blocks, axis=0) if self.blocks else np.empty((0, 1), dtype="float32")
            self.blocks = []
        if len(data) == 0:
            raise RuntimeError("Звук не був записаний.")
        path.parent.mkdir(parents=True, exist_ok=True)
        sf.write(path, data, self.samplerate, subtype="PCM_16")
        return len(data) / float(self.samplerate)


def play_wav(path: Path, output_device: int, output_mode: str = "Stereo"):
    data, rate = sf.read(path, dtype="float32", always_2d=True)
    if data.shape[1] > 1:
        mono = np.mean(data, axis=1, keepdims=True)
    else:
        mono = data[:, :1]
    output_info = sd.query_devices(output_device, "output")
    requested_channels = 2 if str(output_mode).casefold() == "stereo" else 1
    channels = max(1, min(requested_channels, int(output_info["max_output_channels"])))
    payload = mono if channels == 1 else np.repeat(mono, channels, axis=1)
    sd.play(payload, samplerate=rate, device=output_device, blocking=True)
