#!python
"""
stts (python) - Universal STT/TTS Shell Wrapper

Usage:
  ./stts                      # Interactive voice shell
  ./stts --setup              # Configure STT/TTS providers
  ./stts [cmd] [args...]      # Run command with voice output

Testing / simulation:
  ./stts --stt-file file.wav --stt-only
  ./stts --stt-file file.wav            # transcribe and execute

Notes:
  - Config stored in ~/.config/stts-python/
"""

import os
import sys
import json
import subprocess
import platform
import shutil
import urllib.request
import urllib.parse
import tarfile
import zipfile
import threading
import readline
import atexit
import wave
import math
import shlex
import time
import struct
import contextlib
import tempfile
import re
import signal
import difflib
import functools
from pathlib import Path
from dataclasses import dataclass
from typing import Optional, List, Tuple, Dict, Any


def load_dotenv() -> None:
    candidates = []
    try:
        here = Path(__file__).resolve().parent
        candidates.append(Path.cwd() / ".env")
        candidates.append(here / ".env")
        candidates.append(here.parent / ".env")
    except Exception:
        pass

    for p in candidates:
        try:
            if not p.exists() or not p.is_file():
                continue
            for line in p.read_text(encoding="utf-8").splitlines():
                s = line.strip()
                if not s or s.startswith("#"):
                    continue
                if s.lower().startswith("export "):
                    s = s[7:].strip()
                if "=" not in s:
                    continue
                k, v = s.split("=", 1)
                k = k.strip()
                v = v.strip().strip('"').strip("'")
                if not k:
                    continue
                os.environ.setdefault(k, v)
            break
        except Exception:
            continue


load_dotenv()

# In pipeline usage, prefer default SIGPIPE behavior to avoid noisy BrokenPipeError
try:
    signal.signal(signal.SIGPIPE, signal.SIG_DFL)
except Exception:
    pass

_CONFIG_DIR_ENV = os.environ.get("STTS_CONFIG_DIR")
CONFIG_DIR = (Path(_CONFIG_DIR_ENV).expanduser() if _CONFIG_DIR_ENV else (Path.home() / ".config" / "stts-python"))
CONFIG_FILE_JSON = CONFIG_DIR / "config.json"
CONFIG_FILE_YAML = CONFIG_DIR / "config.yaml"
CONFIG_FILE_YML = CONFIG_DIR / "config.yml"
MODELS_DIR = CONFIG_DIR / "models"
BIN_DIR = CONFIG_DIR / "bin"
HISTORY_FILE = CONFIG_DIR / "history"

DEFAULT_CONFIG = {
    "stt_provider": None,
    "tts_provider": None,
    "stt_model": None,
    "stt_gpu_layers": 0,
    "tts_voice": "pl",
    "language": "pl",
    "timeout": 5,
    "auto_tts": True,
    "stream_cmd": False,
    "fast_start": True,
    "mic_device": None,
    "speaker_device": None,
    "audio_auto_switch": True,
    "prompt_voice_first": True,
    "startup_tts": True,
    "vad_enabled": True,
    "vad_silence_ms": 800,
    "vad_threshold_db": -42,
    "safe_mode": False,
    "vosk_auto_install": True,
    "vosk_auto_download": True,
    "piper_auto_install": True,
    "piper_auto_download": True,
    "piper_release_tag": "2023.11.14-2",
    "piper_voice_version": "v1.0.0",
    "nlp2cmd_parallel": False,
}


def apply_env_overrides(config: dict) -> dict:
    if os.environ.get("STTS_TIMEOUT"):
        try:
            config["timeout"] = int(os.environ["STTS_TIMEOUT"])
        except Exception:
            pass
    if os.environ.get("STTS_LANGUAGE"):
        config["language"] = os.environ["STTS_LANGUAGE"].strip() or config.get("language")
    if os.environ.get("STTS_STT_PROVIDER"):
        v = os.environ["STTS_STT_PROVIDER"].strip()
        if v in ("whisper", "whisper.cpp"):
            v = "whisper_cpp"
        config["stt_provider"] = v or None
    if os.environ.get("STTS_STT_MODEL"):
        v = os.environ["STTS_STT_MODEL"].strip()
        config["stt_model"] = v or None
    if os.environ.get("STTS_STT_GPU_LAYERS"):
        try:
            config["stt_gpu_layers"] = int(os.environ["STTS_STT_GPU_LAYERS"].strip())
        except Exception:
            pass
    if os.environ.get("STTS_TTS_VOICE"):
        config["tts_voice"] = os.environ["STTS_TTS_VOICE"].strip() or config.get("tts_voice")
    if os.environ.get("STTS_TTS_PROVIDER"):
        v = os.environ["STTS_TTS_PROVIDER"].strip()
        if v in ("espeak-ng",):
            v = "espeak"
        config["tts_provider"] = v or None
    if os.environ.get("STTS_AUTO_TTS"):
        config["auto_tts"] = os.environ["STTS_AUTO_TTS"].strip() not in ("0", "false", "no", "n")
    if os.environ.get("STTS_MIC_DEVICE"):
        v = os.environ["STTS_MIC_DEVICE"].strip()
        config["mic_device"] = None if v in ("0", "auto", "") else v
    if os.environ.get("STTS_SPEAKER_DEVICE"):
        v = os.environ["STTS_SPEAKER_DEVICE"].strip()
        config["speaker_device"] = None if v in ("0", "auto", "") else v
    if os.environ.get("STTS_AUDIO_AUTO_SWITCH"):
        config["audio_auto_switch"] = os.environ["STTS_AUDIO_AUTO_SWITCH"].strip() not in ("0", "false", "no", "n")
    if os.environ.get("STTS_PROMPT_VOICE_FIRST"):
        config["prompt_voice_first"] = os.environ["STTS_PROMPT_VOICE_FIRST"].strip() not in ("0", "false", "no", "n")
    if os.environ.get("STTS_STARTUP_TTS"):
        config["startup_tts"] = os.environ["STTS_STARTUP_TTS"].strip() not in ("0", "false", "no", "n")
    if os.environ.get("STTS_VAD_ENABLED"):
        config["vad_enabled"] = os.environ["STTS_VAD_ENABLED"].strip() not in ("0", "false", "no", "n")
    if os.environ.get("STTS_VAD_SILENCE_MS"):
        try:
            config["vad_silence_ms"] = int(os.environ["STTS_VAD_SILENCE_MS"])
        except Exception:
            pass
    if os.environ.get("STTS_VAD_THRESHOLD_DB"):
        try:
            config["vad_threshold_db"] = float(os.environ["STTS_VAD_THRESHOLD_DB"])
        except Exception:
            pass
    if os.environ.get("STTS_SAFE_MODE"):
        config["safe_mode"] = os.environ["STTS_SAFE_MODE"].strip() not in ("0", "false", "no", "n")
    if os.environ.get("STTS_VOSK_AUTO_INSTALL"):
        config["vosk_auto_install"] = os.environ["STTS_VOSK_AUTO_INSTALL"].strip() not in ("0", "false", "no", "n")
    if os.environ.get("STTS_VOSK_AUTO_DOWNLOAD"):
        config["vosk_auto_download"] = os.environ["STTS_VOSK_AUTO_DOWNLOAD"].strip() not in ("0", "false", "no", "n")
    if os.environ.get("STTS_PIPER_AUTO_INSTALL"):
        config["piper_auto_install"] = os.environ["STTS_PIPER_AUTO_INSTALL"].strip() not in ("0", "false", "no", "n")
    if os.environ.get("STTS_PIPER_AUTO_DOWNLOAD"):
        config["piper_auto_download"] = os.environ["STTS_PIPER_AUTO_DOWNLOAD"].strip() not in ("0", "false", "no", "n")
    if os.environ.get("STTS_PIPER_RELEASE_TAG"):
        config["piper_release_tag"] = os.environ["STTS_PIPER_RELEASE_TAG"].strip() or config.get("piper_release_tag")
    if os.environ.get("STTS_PIPER_VOICE_VERSION"):
        config["piper_voice_version"] = os.environ["STTS_PIPER_VOICE_VERSION"].strip() or config.get("piper_voice_version")
    if os.environ.get("STTS_STREAM"):
        config["stream_cmd"] = os.environ["STTS_STREAM"].strip() not in ("0", "false", "no", "n")
    if os.environ.get("STTS_FAST_START"):
        v = os.environ["STTS_FAST_START"].strip().lower()
        config["fast_start"] = v not in ("0", "false", "no", "n")
    if os.environ.get("STTS_NLP2CMD_PARALLEL"):
        v = os.environ["STTS_NLP2CMD_PARALLEL"].strip().lower()
        config["nlp2cmd_parallel"] = v not in ("0", "false", "no", "n")
    return config


class Colors:
    RED = '\033[0;31m'
    GREEN = '\033[0;32m'
    YELLOW = '\033[0;33m'
    BLUE = '\033[0;34m'
    MAGENTA = '\033[0;35m'
    CYAN = '\033[0;36m'
    BOLD = '\033[1m'
    NC = '\033[0m'


def cprint(color: str, text: str, end: str = "\n"):
    try:
        print(f"{color}{text}{Colors.NC}", end=end, flush=True)
    except BrokenPipeError:
        return


def _download_progress(count, block_size, total_size):
    percent = int(count * block_size * 100 / total_size) if total_size > 0 else 0
    print(f"\r  Progress: {percent}%", end="", flush=True)


@dataclass
class SystemInfo:
    os_name: str
    os_version: str
    arch: str
    cpu_cores: int
    ram_gb: float
    gpu_name: Optional[str]
    gpu_vram_gb: Optional[float]
    is_rpi: bool
    has_mic: bool


_SYSTEM_INFO_CACHE_FAST: Optional[SystemInfo] = None
_SYSTEM_INFO_CACHE_FULL: Optional[SystemInfo] = None


def detect_system(fast: bool = False) -> SystemInfo:
    global _SYSTEM_INFO_CACHE_FAST, _SYSTEM_INFO_CACHE_FULL
    if fast and _SYSTEM_INFO_CACHE_FAST is not None:
        return _SYSTEM_INFO_CACHE_FAST
    if (not fast) and _SYSTEM_INFO_CACHE_FULL is not None:
        return _SYSTEM_INFO_CACHE_FULL

    os_name = platform.system().lower()
    os_version = platform.release()
    arch = platform.machine()
    cpu_cores = os.cpu_count() or 1

    ram_gb = 4.0
    try:
        if os_name == "linux":
            with open("/proc/meminfo") as f:
                for line in f:
                    if line.startswith("MemTotal:"):
                        ram_kb = int(line.split()[1])
                        ram_gb = ram_kb / 1024 / 1024
                        break
    except:
        pass

    gpu_name = None
    gpu_vram_gb = None
    if not fast:
        try:
            result = subprocess.run(
                ["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"],
                capture_output=True,
                text=True,
                timeout=5,
            )
            if result.returncode == 0:
                parts = result.stdout.strip().split(", ")
                gpu_name = parts[0]
                gpu_vram_gb = float(parts[1]) / 1024 if len(parts) > 1 else None
        except Exception:
            pass

    is_rpi = False
    try:
        if os_name == "linux" and Path("/proc/device-tree/model").exists():
            model = Path("/proc/device-tree/model").read_text()
            is_rpi = "raspberry" in model.lower()
    except:
        pass

    has_mic = False
    if not fast:
        try:
            if os_name == "linux":
                result = subprocess.run(["arecord", "-l"], capture_output=True, text=True)
                has_mic = "card" in result.stdout.lower()
            else:
                has_mic = True
        except Exception:
            pass

    info = SystemInfo(
        os_name=os_name,
        os_version=os_version,
        arch=arch,
        cpu_cores=cpu_cores,
        ram_gb=round(ram_gb, 1),
        gpu_name=gpu_name,
        gpu_vram_gb=round(gpu_vram_gb, 1) if gpu_vram_gb else None,
        is_rpi=is_rpi,
        has_mic=has_mic,
    )

    if fast:
        _SYSTEM_INFO_CACHE_FAST = info
    else:
        _SYSTEM_INFO_CACHE_FULL = info
    return info


def load_config() -> dict:
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    cfg = DEFAULT_CONFIG.copy()
    path = _get_config_file_for_load()
    if path is not None and path.exists():
        try:
            if path.suffix in (".yaml", ".yml"):
                cfg.update(_parse_simple_yaml(path.read_text(encoding="utf-8")))
            else:
                cfg.update(json.loads(path.read_text(encoding="utf-8")))
            return apply_env_overrides(cfg)
        except Exception:
            pass
    return apply_env_overrides(DEFAULT_CONFIG.copy())


def save_config(config: dict) -> None:
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    path = _get_config_file_for_save()
    if path.suffix in (".yaml", ".yml"):
        path.write_text(_dump_simple_yaml(config), encoding="utf-8")
    else:
        path.write_text(json.dumps(config, indent=2), encoding="utf-8")


def _normalize_config_format(v: Optional[str]) -> Optional[str]:
    if not v:
        return None
    s = v.strip().lower()
    if s in ("yaml", "yml"):
        return "yaml"
    if s in ("json",):
        return "json"
    return None


def _get_config_file_for_load() -> Optional[Path]:
    fmt = _normalize_config_format(os.environ.get("STTS_CONFIG_FORMAT"))
    if fmt == "yaml":
        if CONFIG_FILE_YAML.exists():
            return CONFIG_FILE_YAML
        if CONFIG_FILE_YML.exists():
            return CONFIG_FILE_YML
        return CONFIG_FILE_YAML
    if fmt == "json":
        return CONFIG_FILE_JSON

    # auto-detect: prefer YAML if present
    if CONFIG_FILE_YAML.exists():
        return CONFIG_FILE_YAML
    if CONFIG_FILE_YML.exists():
        return CONFIG_FILE_YML
    if CONFIG_FILE_JSON.exists():
        return CONFIG_FILE_JSON
    return CONFIG_FILE_JSON


def _get_config_file_for_save() -> Path:
    fmt = _normalize_config_format(os.environ.get("STTS_CONFIG_FORMAT"))
    if fmt == "yaml":
        # preserve existing .yml if used
        if CONFIG_FILE_YML.exists() and not CONFIG_FILE_YAML.exists():
            return CONFIG_FILE_YML
        return CONFIG_FILE_YAML
    if fmt == "json":
        return CONFIG_FILE_JSON

    # auto: save where user already has config
    if CONFIG_FILE_YAML.exists():
        return CONFIG_FILE_YAML
    if CONFIG_FILE_YML.exists():
        return CONFIG_FILE_YML
    if CONFIG_FILE_JSON.exists():
        return CONFIG_FILE_JSON
    return CONFIG_FILE_JSON


def _parse_simple_yaml(text: str) -> dict:
    """Very small YAML subset parser for flat key: value maps."""
    out: dict = {}
    for raw in (text or "").splitlines():
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        if ":" not in line:
            continue
        k, v = line.split(":", 1)
        key = k.strip()
        if not key:
            continue
        val_s = v.strip()
        if not val_s or val_s in ("null", "~"):
            out[key] = None
            continue

        if (val_s.startswith("\"") and val_s.endswith("\"")) or (val_s.startswith("'") and val_s.endswith("'")):
            out[key] = val_s[1:-1]
            continue

        low = val_s.lower()
        if low in ("true", "yes", "y", "on"):
            out[key] = True
            continue
        if low in ("false", "no", "n", "off"):
            out[key] = False
            continue

        try:
            if any(ch in val_s for ch in (".", "e", "E")):
                out[key] = float(val_s)
            else:
                out[key] = int(val_s)
            continue
        except Exception:
            pass

        out[key] = val_s
    return out


def _dump_simple_yaml(data: dict) -> str:
    """Dump flat dict to YAML (simple key: value)."""
    def fmt(v):
        if v is None:
            return "null"
        if isinstance(v, bool):
            return "true" if v else "false"
        if isinstance(v, (int, float)):
            return str(v)
        s = str(v)
        if s == "":
            return "\"\""
        needs_quote = any(ch.isspace() for ch in s) or any(ch in s for ch in (":", "#", "\"", "'"))
        if needs_quote:
            s2 = s.replace("\\", "\\\\").replace("\"", "\\\"")
            return f"\"{s2}\""
        return s

    lines = []
    for k in sorted(data.keys()):
        if not isinstance(k, str):
            continue
        lines.append(f"{k}: {fmt(data[k])}")
    return "\n".join(lines) + "\n"


def _run_text(cmd: List[str], timeout: int = 3) -> str:
    try:
        res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        return (res.stdout or "") + (res.stderr or "")
    except Exception:
        return ""


def list_capture_devices_linux() -> List[Tuple[str, str]]:
    devices: List[Tuple[str, str]] = []
    out = _run_text(["arecord", "-l"], timeout=3)
    cards = {}
    card = None
    for line in out.splitlines():
        line = line.strip()
        if line.startswith("card ") and ":" in line:
            # card 0: PCH [HDA Intel PCH], device 0: ...
            parts = line.split(":", 1)
            card = parts[0].split()[1]
            cards[card] = parts[1].strip()
            dev = None
            if "device" in parts[0]:
                try:
                    dev = parts[0].split("device", 1)[1].strip().split()[0]
                except Exception:
                    dev = None
            if card is not None and dev is not None:
                devices.append((f"plughw:{card},{dev}", f"{cards.get(card, '')}".strip()))
        elif line.startswith("device ") and card is not None:
            try:
                dev = line.split(":", 1)[0].split()[1]
                devices.append((f"plughw:{card},{dev}", f"{cards.get(card, '')}".strip()))
            except Exception:
                pass

    # common logical devices
    devices.insert(0, ("default", "ALSA default"))
    out_l = _run_text(["arecord", "-L"], timeout=3)
    if "pulse" in out_l.split():
        devices.insert(0, ("pulse", "PulseAudio"))

    seen = set()
    uniq: List[Tuple[str, str]] = []
    for d, desc in devices:
        if d in seen:
            continue
        seen.add(d)
        uniq.append((d, desc))
    return uniq


def list_playback_devices_linux() -> List[Tuple[str, str]]:
    devices: List[Tuple[str, str]] = []
    out = _run_text(["aplay", "-l"], timeout=3)
    cards = {}
    card = None
    for line in out.splitlines():
        line = line.strip()
        if line.startswith("card ") and ":" in line:
            parts = line.split(":", 1)
            card = parts[0].split()[1]
            cards[card] = parts[1].strip()
            dev = None
            if "device" in parts[0]:
                try:
                    dev = parts[0].split("device", 1)[1].strip().split()[0]
                except Exception:
                    dev = None
            if card is not None and dev is not None:
                devices.append((f"plughw:{card},{dev}", f"{cards.get(card, '')}".strip()))
        elif line.startswith("device ") and card is not None:
            try:
                dev = line.split(":", 1)[0].split()[1]
                devices.append((f"plughw:{card},{dev}", f"{cards.get(card, '')}".strip()))
            except Exception:
                pass

    devices.insert(0, ("default", "ALSA default"))
    out_l = _run_text(["aplay", "-L"], timeout=3)
    if "pulse" in out_l.split():
        devices.insert(0, ("pulse", "PulseAudio"))

    seen = set()
    uniq: List[Tuple[str, str]] = []
    for d, desc in devices:
        if d in seen:
            continue
        seen.add(d)
        uniq.append((d, desc))
    return uniq


def get_active_pulse_devices() -> Tuple[Optional[str], Optional[str]]:
    if not shutil.which("pactl"):
        return None, None
    src = None
    sink = None
    out = _run_text(["pactl", "get-default-source"], timeout=2).strip()
    if out:
        src = out.splitlines()[-1].strip()
    out = _run_text(["pactl", "get-default-sink"], timeout=2).strip()
    if out:
        sink = out.splitlines()[-1].strip()
    return src, sink


def analyze_wav(path: str) -> dict:
    try:
        with wave.open(path, "rb") as wf:
            channels = wf.getnchannels()
            rate = wf.getframerate()
            width = wf.getsampwidth()
            frames = wf.getnframes()
            raw = wf.readframes(frames)

        if width not in (1, 2, 4) or not raw:
            return {"ok": False, "reason": "unsupported"}

        if width == 1:
            max_int = 127.0
            # unsigned 8-bit PCM
            samples = [(b - 128) for b in raw]
        elif width == 2:
            max_int = 32767.0
            samples = [int.from_bytes(raw[i:i+2], "little", signed=True) for i in range(0, len(raw), 2)]
        else:
            max_int = 2147483647.0
            samples = [int.from_bytes(raw[i:i+4], "little", signed=True) for i in range(0, len(raw), 4)]

        if channels > 1:
            # average channels
            mono = []
            for i in range(0, len(samples), channels):
                chunk = samples[i:i+channels]
                if not chunk:
                    break
                mono.append(sum(chunk) / len(chunk))
            samples_f = mono
        else:
            samples_f = samples

        n = len(samples_f)
        if n == 0:
            return {"ok": False, "reason": "empty"}

        peak = max(abs(float(s)) for s in samples_f)
        mean_sq = sum((float(s) * float(s)) for s in samples_f) / n
        rms = math.sqrt(mean_sq)

        if rms <= 0:
            rms_db = -120.0
        else:
            rms_db = 20.0 * math.log10(rms / max_int)

        if peak <= 0:
            crest_db = 0.0
        else:
            crest_db = 20.0 * math.log10(peak / (rms + 1e-9))

        dur = float(n) / float(rate)

        cls = "speech"
        if rms_db < -55.0:
            cls = "silence"
        elif crest_db < 6.0 and rms_db > -35.0:
            cls = "noise"

        return {
            "ok": True,
            "channels": channels,
            "rate": rate,
            "width": width,
            "duration_s": round(dur, 2),
            "rms_dbfs": round(rms_db, 1),
            "crest_db": round(crest_db, 1),
            "class": cls,
        }
    except Exception:
        return {"ok": False, "reason": "read_error"}


def choose_device_interactive(title: str, devices: List[Tuple[str, str]]) -> Optional[str]:
    cprint(Colors.CYAN, f"\n{title}")
    print("  0. auto")
    for i, (dev, desc) in enumerate(devices, 1):
        print(f"  {i}. {dev}  {desc}")
    while True:
        sel = input("Wybór (0=auto): ").strip()
        if sel == "" or sel == "0":
            return None
        try:
            idx = int(sel)
            if 1 <= idx <= len(devices):
                return devices[idx - 1][0]
        except ValueError:
            # allow paste device name
            for dev, _ in devices:
                if sel == dev:
                    return dev
        cprint(Colors.RED, "❌ Nieprawidłowy wybór")


def _download_progress(count, block_size, total_size):
    percent = int(count * block_size * 100 / total_size) if total_size > 0 else 0
    print(f"\r  Progress: {percent}%", end="", flush=True)


@dataclass
class SystemInfo:
    os_name: str
    os_version: str
    arch: str
    cpu_cores: int
    ram_gb: float
    gpu_name: Optional[str]
    gpu_vram_gb: Optional[float]
    is_rpi: bool
    has_mic: bool


_SYSTEM_INFO_CACHE_FAST: Optional[SystemInfo] = None
_SYSTEM_INFO_CACHE_FULL: Optional[SystemInfo] = None


def detect_system(fast: bool = False) -> SystemInfo:
    global _SYSTEM_INFO_CACHE_FAST, _SYSTEM_INFO_CACHE_FULL
    if fast and _SYSTEM_INFO_CACHE_FAST is not None:
        return _SYSTEM_INFO_CACHE_FAST
    if (not fast) and _SYSTEM_INFO_CACHE_FULL is not None:
        return _SYSTEM_INFO_CACHE_FULL

    os_name = platform.system().lower()
    os_version = platform.release()
    arch = platform.machine()
    cpu_cores = os.cpu_count() or 1

    ram_gb = 4.0
    try:
        if os_name == "linux":
            with open("/proc/meminfo") as f:
                for line in f:
                    if line.startswith("MemTotal:"):
                        ram_kb = int(line.split()[1])
                        ram_gb = ram_kb / 1024 / 1024
                        break
    except:
        pass

    gpu_name = None
    gpu_vram_gb = None
    if not fast:
        try:
            result = subprocess.run(
                ["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"],
                capture_output=True,
                text=True,
                timeout=5,
            )
            if result.returncode == 0:
                parts = result.stdout.strip().split(", ")
                gpu_name = parts[0]
                gpu_vram_gb = float(parts[1]) / 1024 if len(parts) > 1 else None
        except Exception:
            pass

    is_rpi = False
    try:
        if os_name == "linux" and Path("/proc/device-tree/model").exists():
            model = Path("/proc/device-tree/model").read_text()
            is_rpi = "raspberry" in model.lower()
    except:
        pass

    has_mic = False
    if not fast:
        try:
            if os_name == "linux":
                result = subprocess.run(["arecord", "-l"], capture_output=True, text=True)
                has_mic = "card" in result.stdout.lower()
            else:
                has_mic = True
        except Exception:
            pass

    info = SystemInfo(
        os_name=os_name,
        os_version=os_version,
        arch=arch,
        cpu_cores=cpu_cores,
        ram_gb=round(ram_gb, 1),
        gpu_name=gpu_name,
        gpu_vram_gb=round(gpu_vram_gb, 1) if gpu_vram_gb else None,
        is_rpi=is_rpi,
        has_mic=has_mic,
    )

    if fast:
        _SYSTEM_INFO_CACHE_FAST = info
    else:
        _SYSTEM_INFO_CACHE_FULL = info
    return info


def load_config() -> dict:
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    cfg = DEFAULT_CONFIG.copy()
    path = _get_config_file_for_load()
    if path is not None and path.exists():
        try:
            if path.suffix in (".yaml", ".yml"):
                cfg.update(_parse_simple_yaml(path.read_text(encoding="utf-8")))
            else:
                cfg.update(json.loads(path.read_text(encoding="utf-8")))
            return apply_env_overrides(cfg)
        except Exception:
            pass
    return apply_env_overrides(DEFAULT_CONFIG.copy())


def save_config(config: dict) -> None:
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    path = _get_config_file_for_save()
    if path.suffix in (".yaml", ".yml"):
        path.write_text(_dump_simple_yaml(config), encoding="utf-8")
    else:
        path.write_text(json.dumps(config, indent=2), encoding="utf-8")


def _normalize_config_format(v: Optional[str]) -> Optional[str]:
    if not v:
        return None
    s = v.strip().lower()
    if s in ("yaml", "yml"):
        return "yaml"
    if s in ("json",):
        return "json"
    return None


def _get_config_file_for_load() -> Optional[Path]:
    fmt = _normalize_config_format(os.environ.get("STTS_CONFIG_FORMAT"))
    if fmt == "yaml":
        if CONFIG_FILE_YAML.exists():
            return CONFIG_FILE_YAML
        if CONFIG_FILE_YML.exists():
            return CONFIG_FILE_YML
        return CONFIG_FILE_YAML
    if fmt == "json":
        return CONFIG_FILE_JSON

    # auto-detect: prefer YAML if present
    if CONFIG_FILE_YAML.exists():
        return CONFIG_FILE_YAML
    if CONFIG_FILE_YML.exists():
        return CONFIG_FILE_YML
    if CONFIG_FILE_JSON.exists():
        return CONFIG_FILE_JSON
    return CONFIG_FILE_JSON


def _get_config_file_for_save() -> Path:
    fmt = _normalize_config_format(os.environ.get("STTS_CONFIG_FORMAT"))
    if fmt == "yaml":
        # preserve existing .yml if used
        if CONFIG_FILE_YML.exists() and not CONFIG_FILE_YAML.exists():
            return CONFIG_FILE_YML
        return CONFIG_FILE_YAML
    if fmt == "json":
        return CONFIG_FILE_JSON

    # auto: save where user already has config
    if CONFIG_FILE_YAML.exists():
        return CONFIG_FILE_YAML
    if CONFIG_FILE_YML.exists():
        return CONFIG_FILE_YML
    if CONFIG_FILE_JSON.exists():
        return CONFIG_FILE_JSON
    return CONFIG_FILE_JSON


def _parse_simple_yaml(text: str) -> dict:
    """Very small YAML subset parser for flat key: value maps."""
    out: dict = {}
    for raw in (text or "").splitlines():
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        if ":" not in line:
            continue
        k, v = line.split(":", 1)
        key = k.strip()
        if not key:
            continue
        val_s = v.strip()
        if not val_s or val_s in ("null", "~"):
            out[key] = None
            continue

        if (val_s.startswith("\"") and val_s.endswith("\"")) or (val_s.startswith("'") and val_s.endswith("'")):
            out[key] = val_s[1:-1]
            continue

        low = val_s.lower()
        if low in ("true", "yes", "y", "on"):
            out[key] = True
            continue
        if low in ("false", "no", "n", "off"):
            out[key] = False
            continue

        try:
            if any(ch in val_s for ch in (".", "e", "E")):
                out[key] = float(val_s)
            else:
                out[key] = int(val_s)
            continue
        except Exception:
            pass

        out[key] = val_s
    return out


def _dump_simple_yaml(data: dict) -> str:
    """Dump flat dict to YAML (simple key: value)."""
    def fmt(v):
        if v is None:
            return "null"
        if isinstance(v, bool):
            return "true" if v else "false"
        if isinstance(v, (int, float)):
            return str(v)
        s = str(v)
        if s == "":
            return "\"\""
        needs_quote = any(ch.isspace() for ch in s) or any(ch in s for ch in (":", "#", "\"", "'"))
        if needs_quote:
            s2 = s.replace("\\", "\\\\").replace("\"", "\\\"")
            return f"\"{s2}\""
        return s

    lines = []
    for k in sorted(data.keys()):
        if not isinstance(k, str):
            continue
        lines.append(f"{k}: {fmt(data[k])}")
    return "\n".join(lines) + "\n"


def _run_text(cmd: List[str], timeout: int = 3) -> str:
    try:
        res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        return (res.stdout or "") + (res.stderr or "")
    except Exception:
        return ""


def list_capture_devices_linux() -> List[Tuple[str, str]]:
    devices: List[Tuple[str, str]] = []
    out = _run_text(["arecord", "-l"], timeout=3)
    cards = {}
    card = None
    for line in out.splitlines():
        line = line.strip()
        if line.startswith("card ") and ":" in line:
            # card 0: PCH [HDA Intel PCH], device 0: ...
            parts = line.split(":", 1)
            card = parts[0].split()[1]
            cards[card] = parts[1].strip()
            dev = None
            if "device" in parts[0]:
                try:
                    dev = parts[0].split("device", 1)[1].strip().split()[0]
                except Exception:
                    dev = None
            if card is not None and dev is not None:
                devices.append((f"plughw:{card},{dev}", f"{cards.get(card, '')}".strip()))
        elif line.startswith("device ") and card is not None:
            try:
                dev = line.split(":", 1)[0].split()[1]
                devices.append((f"plughw:{card},{dev}", f"{cards.get(card, '')}".strip()))
            except Exception:
                pass

    # common logical devices
    devices.insert(0, ("default", "ALSA default"))
    out_l = _run_text(["arecord", "-L"], timeout=3)
    if "pulse" in out_l.split():
        devices.insert(0, ("pulse", "PulseAudio"))

    seen = set()
    uniq: List[Tuple[str, str]] = []
    for d, desc in devices:
        if d in seen:
            continue
        seen.add(d)
        uniq.append((d, desc))
    return uniq


def list_playback_devices_linux() -> List[Tuple[str, str]]:
    devices: List[Tuple[str, str]] = []
    out = _run_text(["aplay", "-l"], timeout=3)
    cards = {}
    card = None
    for line in out.splitlines():
        line = line.strip()
        if line.startswith("card ") and ":" in line:
            parts = line.split(":", 1)
            card = parts[0].split()[1]
            cards[card] = parts[1].strip()
            dev = None
            if "device" in parts[0]:
                try:
                    dev = parts[0].split("device", 1)[1].strip().split()[0]
                except Exception:
                    dev = None
            if card is not None and dev is not None:
                devices.append((f"plughw:{card},{dev}", f"{cards.get(card, '')}".strip()))
        elif line.startswith("device ") and card is not None:
            try:
                dev = line.split(":", 1)[0].split()[1]
                devices.append((f"plughw:{card},{dev}", f"{cards.get(card, '')}".strip()))
            except Exception:
                pass

    devices.insert(0, ("default", "ALSA default"))
    out_l = _run_text(["aplay", "-L"], timeout=3)
    if "pulse" in out_l.split():
        devices.insert(0, ("pulse", "PulseAudio"))

    seen = set()
    uniq: List[Tuple[str, str]] = []
    for d, desc in devices:
        if d in seen:
            continue
        seen.add(d)
        uniq.append((d, desc))
    return uniq


def get_active_pulse_devices() -> Tuple[Optional[str], Optional[str]]:
    if not shutil.which("pactl"):
        return None, None
    src = None
    sink = None
    out = _run_text(["pactl", "get-default-source"], timeout=2).strip()
    if out:
        src = out.splitlines()[-1].strip()
    out = _run_text(["pactl", "get-default-sink"], timeout=2).strip()
    if out:
        sink = out.splitlines()[-1].strip()
    return src, sink


def analyze_wav(path: str) -> dict:
    try:
        with wave.open(path, "rb") as wf:
            channels = wf.getnchannels()
            rate = wf.getframerate()
            width = wf.getsampwidth()
            frames = wf.getnframes()
            raw = wf.readframes(frames)

        if width not in (1, 2, 4) or not raw:
            return {"ok": False, "reason": "unsupported"}

        if width == 1:
            max_int = 127.0
            # unsigned 8-bit PCM
            samples = [(b - 128) for b in raw]
        elif width == 2:
            max_int = 32767.0
            samples = [int.from_bytes(raw[i:i+2], "little", signed=True) for i in range(0, len(raw), 2)]
        else:
            max_int = 2147483647.0
            samples = [int.from_bytes(raw[i:i+4], "little", signed=True) for i in range(0, len(raw), 4)]

        if channels > 1:
            # average channels
            mono = []
            for i in range(0, len(samples), channels):
                chunk = samples[i:i+channels]
                if not chunk:
                    break
                mono.append(sum(chunk) / len(chunk))
            samples_f = mono
        else:
            samples_f = samples

        n = len(samples_f)
        if n == 0:
            return {"ok": False, "reason": "empty"}

        peak = max(abs(float(s)) for s in samples_f)
        mean_sq = sum((float(s) * float(s)) for s in samples_f) / n
        rms = math.sqrt(mean_sq)

        if rms <= 0:
            rms_db = -120.0
        else:
            rms_db = 20.0 * math.log10(rms / max_int)

        if peak <= 0:
            crest_db = 0.0
        else:
            crest_db = 20.0 * math.log10(peak / (rms + 1e-9))

        dur = float(n) / float(rate)

        cls = "speech"
        if rms_db < -55.0:
            cls = "silence"
        elif crest_db < 6.0 and rms_db > -35.0:
            cls = "noise"

        return {
            "ok": True,
            "channels": channels,
            "rate": rate,
            "width": width,
            "duration_s": round(dur, 2),
            "rms_dbfs": round(rms_db, 1),
            "crest_db": round(crest_db, 1),
            "class": cls,
        }
    except Exception:
        return {"ok": False, "reason": "read_error"}


def choose_device_interactive(title: str, devices: List[Tuple[str, str]]) -> Optional[str]:
    cprint(Colors.CYAN, f"\n{title}")
    print("  0. auto")
    for i, (dev, desc) in enumerate(devices, 1):
        print(f"  {i}. {dev}  {desc}")
    while True:
        sel = input("Wybór (0=auto): ").strip()
        if sel == "" or sel == "0":
            return None
        try:
            idx = int(sel)
            if 1 <= idx <= len(devices):
                return devices[idx - 1][0]
        except ValueError:
            # allow paste device name
            for dev, _ in devices:
                if sel == dev:
                    return dev
        cprint(Colors.RED, "❌ Nieprawidłowy wybór")


def _arecord_raw(device: Optional[str], seconds: float, rate: int = 16000) -> bytes:
    cmd = ["arecord"]
    if device:
        cmd += ["-D", device]
    cmd += [
        "-q",
        "-d",
        str(max(1, int(math.ceil(seconds)))),
        "-r",
        str(rate),
        "-c",
        "1",
        "-f",
        "S16_LE",
        "-t",
        "raw",
        "-",
    ]
    try:
        res = subprocess.run(cmd, capture_output=True, timeout=max(2, int(seconds) + 2))
        return res.stdout or b""
    except Exception:
        return b""


def _rms_dbfs_s16le(raw: bytes) -> float:
    if not raw:
        return -120.0
    n = len(raw) // 2
    if n <= 0:
        return -120.0
    try:
        samples = struct.unpack("<" + "h" * n, raw[: n * 2])
    except Exception:
        return -120.0
    mean_sq = sum((float(s) * float(s)) for s in samples) / float(n)
    rms = math.sqrt(mean_sq)
    if rms <= 0:
        return -120.0
    return 20.0 * math.log10(rms / 32767.0)


def mic_meter(devices: List[Tuple[str, str]], seconds: float = 0.8, loops: int = 0) -> dict:
    scores: dict = {d: -120.0 for d, _ in devices}
    i = 0
    while True:
        i += 1
        print("\033[2J\033[H", end="")
        cprint(Colors.CYAN, "Mów do mikrofonu teraz (meter). Ctrl+C = stop")
        for idx, (dev, desc) in enumerate(devices, 1):
            raw = _arecord_raw(dev if dev not in ("auto", "0") else None, seconds)
            db = _rms_dbfs_s16le(raw)
            scores[dev] = db
            bar_len = max(0, min(30, int((db + 60) * 0.8)))
            bar = "#" * bar_len
            print(f"{idx:2d}. {dev:10s} {db:6.1f} dBFS  {bar}  {desc}")
        print("\nWybierz numer mikrofonu (0=auto), ENTER=odśwież: ", end="", flush=True)
        try:
            sel = input().strip()
        except KeyboardInterrupt:
            print()
            return {"selected": None, "scores": scores}
        if sel == "":
            if loops and i >= loops:
                return {"selected": None, "scores": scores}
            continue
        if sel in ("0", "auto"):
            return {"selected": None, "scores": scores}
        try:
            nsel = int(sel)
            if 1 <= nsel <= len(devices):
                return {"selected": devices[nsel - 1][0], "scores": scores}
        except ValueError:
            pass


def auto_detect_mic(devices: List[Tuple[str, str]], seconds: float = 0.8, rounds: int = 2) -> Optional[str]:
    cprint(Colors.CYAN, "Mów teraz normalnie do mikrofonu (auto-detekcja)...")
    best_dev = None
    best_db = -120.0
    for _ in range(rounds):
        for dev, _ in devices:
            raw = _arecord_raw(dev if dev not in ("auto", "0") else None, seconds)
            db = _rms_dbfs_s16le(raw)
            if db > best_db:
                best_db = db
                best_dev = dev
    if best_dev and best_db > -55.0:
        cprint(Colors.GREEN, f"✅ Wykryto mikrofon: {best_dev} (rms ~ {best_db:.1f} dBFS)")
        return best_dev
    cprint(Colors.YELLOW, "⚠️  Nie wykryto sensownego sygnału (cisza). Uruchom 'meter' aby wybrać ręcznie.")
    return None


class TextNormalizer:
    """Normalizuje i koryguje tekst z STT dla poleceń shell."""

    SHELL_CORRECTIONS = {
        "el es": "ls",
        "el s": "ls",
        "lista": "ls",
        "l s": "ls",
        "kopi": "cp",
        "kopiuj": "cp",
        "przenieś": "mv",
        "usuń": "rm",
        "katalog": "mkdir",
        "pokaż": "cat",
        "edytuj": "nano",
        "eko": "echo",
        "cd..": "cd ..",
        "cd -": "cd -",
        "git status": "git status",
        "git commit": "git commit",
        "git pusz": "git push",
        "git pul": "git pull",
        "pip instal": "pip install",
        "sudo apt instal": "sudo apt install",
    }

    PHONETIC_EN_CORRECTIONS = {
        "serwer": "server",
        "servera": "server",
        "serwera": "server",
        "serwerze": "server",
        "serwery": "servers",
        "endżineks": "nginx",
        "endżinks": "nginx",
        "enginx": "nginx",
        "engines": "nginx",
        "endżin": "engine",
        "endżiny": "engines",
        "dokker": "docker",
        "doker": "docker",
        "dockera": "docker",
        "dokera": "docker",
        "kubernetis": "kubernetes",
        "kubernitis": "kubernetes",
        "kej eight": "k8s",
        "kej ejtis": "k8s",
        "kej ejts": "k8s",
        "kej8s": "k8s",
        "kubektl": "kubectl",
        "kubkontrol": "kubectl",
        "kubctl": "kubectl",
        "postgresem": "postgres",
        "postgresom": "postgres",
        "postgresa": "postgres",
        "postgressa": "postgres",
        "eskuel": "sql",
        "es kju el": "sql",
        "eskjuel": "sql",
        "majeskuel": "mysql",
        "majsql": "mysql",
        "mysquel": "mysql",
        "mongo di bi": "mongodb",
        "mongodi": "mongodb",
        "mongołdi": "mongodb",
        "redisa": "redis",
        "redys": "redis",
        "elastik": "elastic",
        "elasticsearch": "elasticsearch",
        "elastiksercz": "elasticsearch",
        "apacz": "apache",
        "apatche": "apache",
        "apacze": "apache",
        "nodżejejs": "nodejs",
        "noddżejs": "nodejs",
        "nodżs": "nodejs",
        "node dżejs": "nodejs",
        "nodjs": "nodejs",
        "piton": "python",
        "pajton": "python",
        "pajtona": "python",
        "pytona": "python",
        "dżawa": "java",
        "dżawy": "java",
        "dżawą": "java",
        "dżawaskrypt": "javascript",
        "jawa skrypt": "javascript",
        "dżejs": "js",
        "jst": "js",
        "tajpskrypt": "typescript",
        "tajp skrypt": "typescript",
        "reakt": "react",
        "reakta": "react",
        "wju": "vue",
        "wjuejs": "vuejs",
        "angulara": "angular",
        "angularem": "angular",
        "netflajs": "nextjs",
        "nekst dżejs": "nextjs",
        "nestjs": "nestjs",
        "ekspres": "express",
        "ekspresa": "express",
        "flaskem": "flask",
        "dżango": "django",
        "dżanga": "django",
        "laravel": "laravel",
        "larawel": "laravel",
        "symfonią": "symfony",
        "springa": "spring",
        "springbutem": "springboot",
        "majkroserwisy": "microservices",
        "majkroservisy": "microservices",
        "mikroserwisy": "microservices",
        "rest ejpi aj": "rest api",
        "rest api": "rest api",
        "restejpiaj": "rest api",
        "dżejson": "json",
        "jsoń": "json",
        "jaml": "yaml",
        "jamł": "yaml",
        "tomł": "toml",
        "toml": "toml",
        "iniajalizuj": "initialize",
        "initializuj": "initialize",
        "inital": "init",
        "inituj": "init",
        "deploj": "deploy",
        "deplojuj": "deploy",
        "deplojem": "deploy",
        "deploymenta": "deployment",
        "deploimentu": "deployment",
        "bilda": "build",
        "bildem": "build",
        "bilduj": "build",
        "starta": "start",
        "startuj": "start",
        "restarta": "restart",
        "restartuj": "restart",
        "stopa": "stop",
        "stopuj": "stop",
        "testa": "test",
        "testuj": "test",
        "lintuj": "lint",
        "lintera": "linter",
        "awsa": "aws",
        "awsie": "aws",
        "ejdablju es": "aws",
        "azura": "azure",
        "ejżur": "azure",
        "dżi si pi": "gcp",
        "google cloud": "gcp",
        "heroku": "heroku",
        "netlify": "netlify",
        "wercel": "vercel",
        "wersela": "vercel",
        "terraforma": "terraform",
        "teraform": "terraform",
        "ansibla": "ansible",
        "ansiblem": "ansible",
        "dżenkinsem": "jenkins",
        "dżenkinsa": "jenkins",
        "jenkinsa": "jenkins",
        "siajaidi": "ci/cd",
        "ci cd": "ci/cd",
        "si aj si di": "ci/cd",
        "githuba": "github",
        "gitlaba": "gitlab",
        "gitłab": "gitlab",
        "bitketa": "bitbucket",
        "bitbaketa": "bitbucket",
        "prullrikłest": "pull request",
        "pul rekłest": "pull request",
        "pul rikłest": "pull request",
        "merdż": "merge",
        "merdżuj": "merge",
        "brenczem": "branch",
        "brancz": "branch",
        "brencza": "branch",
        "komitta": "commit",
        "komituj": "commit",
        "komitem": "commit",
        "kontejnera": "container",
        "kontejner": "container",
        "kontener": "container",
        "kontenera": "container",
        "imidża": "image",
        "imidż": "image",
        "imidżem": "image",
        "wolumena": "volume",
        "wolumen": "volume",
        "networkiem": "network",
        "sieć": "network",
        "sieci": "network",
        "serwisem": "service",
        "serwisy": "services",
        "serwis": "service",
        "podów": "pods",
        "pody": "pods",
        "podem": "pod",
        "namespacie": "namespace",
        "nejmspejs": "namespace",
        "helma": "helm",
        "helmem": "helm",
        "istio": "istio",
        "prometheusa": "prometheus",
        "prometeus": "prometheus",
        "grafaną": "grafana",
        "grafana": "grafana",
        "kibaną": "kibana",
        "kibana": "kibana",
        "logstashem": "logstash",
        "logstasz": "logstash",
        "rabbitmq": "rabbitmq",
        "rabit em kju": "rabbitmq",
        "kafką": "kafka",
        "kafki": "kafka",
        "kafkem": "kafka",
        "celery": "celery",
        "selerym": "celery",
    }

    REGEX_FIXES = [
        (r"\bel\s+es\b", "ls"),
        (r"\bel\s+s\b", "ls"),
        (r"\bl\s+s\b", "ls"),
        (r"\bgit\s+stat\b", "git status"),
        (r"\bgit\s+pusz\b", "git push"),
        (r"\bgit\s+pul\b", "git pull"),
        (r"\bgrepp?\b", "grep"),
        (r"\bsudo\s+apt\s+instal\b", "sudo apt install"),
        (r"\bpip\s+instal\b", "pip install"),
        (r"\beko\s+", "echo "),
        (r"\bkopi\s+", "cp "),
        (r"\bmkdir\s+-p\s*", "mkdir -p "),
        (r"\bservera?\s+engines?\b", "nginx server"),
        (r"\bserwera?\s+engines?\b", "nginx server"),
        (r"\bserwer\s+endżi?n?e?ks?\b", "nginx server"),
        (r"\bdocker\s+kompo[uz]e?\b", "docker compose"),
        (r"\bdocker\s+kompoza?\b", "docker compose"),
        (r"\bdokker\s+kompo[uz]e?\b", "docker compose"),
        (r"\bkube?r?netis\b", "kubernetes"),
        (r"\bkube?rnitis\b", "kubernetes"),
    ]

    @classmethod
    def normalize(cls, text: str, language: str = "pl") -> str:
        """Normalizuje tekst STT - usuwa błędy, poprawia komendy."""
        if not text:
            return ""

        result = text.strip()

        result = re.sub(r"[.,!?;:]+$", "", result)

        for pattern, replacement in cls.REGEX_FIXES:
            result = re.sub(pattern, replacement, result, flags=re.IGNORECASE)

        result = cls._fix_phonetic_english(result, language)

        lower = result.lower().strip()
        for wrong, correct in cls.SHELL_CORRECTIONS.items():
            if lower == wrong:
                return correct

        return result

    @classmethod
    def _fix_phonetic_english(cls, text: str, language: str = "pl") -> str:
        """Poprawia angielskie słowa techniczne zapisane fonetycznie po polsku."""
        if (language or "pl").lower() not in ("pl", "polish"):
            return text

        words = text.split()
        fixed = []
        for word in words:
            m = re.match(r"^([.,!?;:\"\'()\[\]{}]*)(.*?)([.,!?;:\"\'()\[\]{}]*)$", word)
            if not m:
                fixed.append(word)
                continue
            prefix, core, suffix = m.groups()
            if not core:
                fixed.append(word)
                continue

            clean = core.lower()
            replacement = cls.PHONETIC_EN_CORRECTIONS.get(clean)
            if replacement is None:
                replacement = cls._fuzzy_phonetic_replacement(clean)
            if replacement is None:
                fixed.append(word)
                continue

            if core[:1].isupper() and replacement[:1].isalpha():
                replacement = replacement[:1].upper() + replacement[1:]
            fixed.append(f"{prefix}{replacement}{suffix}")
        return " ".join(fixed)

    @staticmethod
    @functools.lru_cache(maxsize=4096)
    def _fuzzy_phonetic_replacement(clean: str) -> Optional[str]:
        s = (clean or "").strip().lower()
        if not s:
            return None
        if len(s) < 4 or len(s) > 18:
            return None
        if not s.isalpha():
            return None

        keys = [k for k in TextNormalizer.PHONETIC_EN_CORRECTIONS.keys() if " " not in k]
        candidates = [k for k in keys if abs(len(k) - len(s)) <= 2]
        if not candidates:
            return None

        try:
            from rapidfuzz import fuzz as _rf_fuzz  # type: ignore
            from rapidfuzz import process as _rf_process  # type: ignore

            hit = _rf_process.extractOne(s, candidates, scorer=_rf_fuzz.ratio, score_cutoff=84)
            if not hit:
                return None
            return TextNormalizer.PHONETIC_EN_CORRECTIONS.get(hit[0])
        except Exception:
            pass

        m = difflib.get_close_matches(s, candidates, n=1, cutoff=0.84)
        if not m:
            return None
        return TextNormalizer.PHONETIC_EN_CORRECTIONS.get(m[0])


class STTProvider:
    name: str = "base"
    description: str = "Base"
    min_ram_gb: float = 0.5
    models: List[Tuple[str, str, float]] = []

    @classmethod
    def is_available(cls, info: SystemInfo):
        return False, "Not implemented"

    @classmethod
    def install(cls, info: SystemInfo) -> bool:
        return False

    @classmethod
    def get_recommended_model(cls, info: SystemInfo) -> Optional[str]:
        return None

    def __init__(
        self,
        model: Optional[str] = None,
        language: str = "pl",
        config: Optional[dict] = None,
        info: Optional[SystemInfo] = None,
    ):
        self.model = model
        self.language = language
        self.config = config or {}
        self.info = info

    def transcribe(self, audio_path: str) -> str:
        raise NotImplementedError


class WhisperCppSTT(STTProvider):
    name = "whisper.cpp"
    description = "Offline, fast, CPU-optimized Whisper (recommended)"
    min_ram_gb = 1.0
    models = [
        ("tiny", "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin", 0.08),
        ("base", "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin", 0.15),
        ("small", "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin", 0.5),
        ("medium", "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin", 1.5),
        ("large", "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin", 3.0),
    ]

    @classmethod
    def is_available(cls, info: SystemInfo):
        for b in ("whisper-cli", "whisper-cpp"):
            if shutil.which(b):
                return True, f"{b} found"
        # legacy
        if shutil.which("main"):
            return True, "main found"
        for p in (
            MODELS_DIR / "whisper.cpp" / "build" / "bin" / "whisper-cli",
            MODELS_DIR / "whisper.cpp" / "build" / "bin" / "main",
            MODELS_DIR / "whisper.cpp" / "main",
        ):
            if p.exists():
                return True, f"whisper.cpp at {p}"
        return False, "whisper.cpp not installed"

    @classmethod
    def get_recommended_model(cls, info: SystemInfo) -> str:
        if info.ram_gb < 2:
            return "tiny"
        if info.ram_gb < 4:
            return "base"
        if info.ram_gb < 8:
            return "small"
        if info.ram_gb < 16:
            return "medium"
        return "large"

    @classmethod
    def _detect_cuda(cls) -> bool:
        """Sprawdź czy CUDA toolkit jest dostępny."""
        try:
            result = subprocess.run(
                ["nvcc", "--version"],
                capture_output=True, text=True, timeout=5
            )
            return result.returncode == 0
        except Exception:
            return False

    @classmethod
    def _has_gpu_build(cls) -> bool:
        """Sprawdź czy whisper.cpp zbudowano z GPU."""
        marker = MODELS_DIR / "whisper.cpp" / ".gpu_build"
        return marker.exists()

    @classmethod
    def install(cls, info: SystemInfo, force_gpu: Optional[bool] = None) -> bool:
        cprint(Colors.YELLOW, "📦 Installing whisper.cpp...")
        whisper_dir = MODELS_DIR / "whisper.cpp"
        whisper_dir.mkdir(parents=True, exist_ok=True)

        already_built = any(
            p.exists()
            for p in (
                whisper_dir / "build" / "bin" / "whisper-cli",
                whisper_dir / "build" / "bin" / "main",
                whisper_dir / "main",
            )
        )
        if already_built:
            gpu_info = " (GPU)" if cls._has_gpu_build() else " (CPU)"
            cprint(Colors.GREEN, f"✅ whisper.cpp already installed!{gpu_info}")
            return True

        try:
            if not (whisper_dir / "Makefile").exists():
                subprocess.run(
                    ["git", "clone", "https://github.com/ggerganov/whisper.cpp", str(whisper_dir)],
                    check=True,
                )

            use_cuda = force_gpu if force_gpu is not None else cls._detect_cuda()
            env_gpu = os.environ.get("STTS_GPU_ENABLED", "").strip().lower()
            if env_gpu in ("1", "true", "yes"):
                use_cuda = True
            elif env_gpu in ("0", "false", "no"):
                use_cuda = False

            if use_cuda:
                cprint(Colors.GREEN, "🎮 CUDA detected - building with GPU support...")
                build_dir = whisper_dir / "build"
                build_dir.mkdir(exist_ok=True)
                subprocess.run(
                    ["cmake", "..", "-DGGML_CUDA=ON", "-DCMAKE_BUILD_TYPE=Release"],
                    cwd=build_dir, check=True
                )
                subprocess.run(
                    ["cmake", "--build", ".", "--config", "Release", "-j"],
                    cwd=build_dir, check=True
                )
                (whisper_dir / ".gpu_build").write_text("cuda")
            else:
                cprint(Colors.YELLOW, "📦 Building CPU-only version...")
                subprocess.run(["make", "-j"], cwd=whisper_dir, check=True)

            cprint(Colors.GREEN, "✅ whisper.cpp installed!")
            return True
        except Exception as e:
            cprint(Colors.RED, f"❌ Installation failed: {e}")
            return False

    @classmethod
    def download_model(cls, model_name: str) -> Optional[Path]:
        model_info = next((m for m in cls.models if m[0] == model_name), None)
        if not model_info:
            cprint(Colors.RED, f"❌ Unknown model: {model_name}")
            return None

        name, url, size = model_info
        expected_bytes = int(float(size) * 1024 * 1024 * 1024)
        if name == "large":
            # upstream naming changed to large-v3; accept both
            p1 = MODELS_DIR / "whisper.cpp" / "ggml-large.bin"
            p2 = MODELS_DIR / "whisper.cpp" / "ggml-large-v3.bin"
            if p2.exists() and p2.stat().st_size > max(1024 * 1024, int(expected_bytes * 0.9)):
                return p2
            if p1.exists() and p1.stat().st_size > max(1024 * 1024, int(expected_bytes * 0.9)):
                return p1
            model_path = p2
        else:
            model_path = MODELS_DIR / "whisper.cpp" / f"ggml-{name}.bin"
            if model_path.exists() and model_path.stat().st_size > max(1024 * 1024, int(expected_bytes * 0.9)):
                return model_path

        if model_path.exists() and model_path.stat().st_size > max(1024 * 1024, int(expected_bytes * 0.9)):
            return model_path

        cprint(Colors.YELLOW, f"📥 Downloading {name} model ({size} GB)...")
        model_path.parent.mkdir(parents=True, exist_ok=True)
        try:
            urllib.request.urlretrieve(url, model_path, _download_progress)
            print()
            cprint(Colors.GREEN, f"✅ Model {name} downloaded!")
            return model_path
        except Exception as e:
            cprint(Colors.RED, f"❌ Download failed: {e}")
            return None

    @staticmethod
    @functools.lru_cache(maxsize=32)
    def _help_text(whisper_bin: str) -> str:
        for args in (("--help",), ("-h",), ("-help",)):
            try:
                res = subprocess.run(
                    [whisper_bin, *args],
                    capture_output=True,
                    text=True,
                    timeout=2,
                )
                out = (res.stdout or "") + (res.stderr or "")
                if out.strip():
                    return out
            except Exception:
                continue
        return ""

    @staticmethod
    def _is_short_audio(audio_path: str, max_seconds: float = 8.0) -> bool:
        try:
            import wave

            with wave.open(audio_path, "rb") as wf:
                frames = int(wf.getnframes() or 0)
                rate = int(wf.getframerate() or 0)
                if rate <= 0:
                    return False
                dur = float(frames) / float(rate)
                return dur <= float(max_seconds)
        except Exception:
            return False

    @staticmethod
    @functools.lru_cache(maxsize=128)
    def _supports_help_token(whisper_bin: str, token: str) -> bool:
        return token in WhisperCppSTT._help_text(whisper_bin)

    @staticmethod
    @functools.lru_cache(maxsize=32)
    def _detect_prompt_flag(whisper_bin: str) -> Optional[str]:
        out = WhisperCppSTT._help_text(whisper_bin)
        if "--prompt" in out:
            return "--prompt"
        if re.search(r"(?mi)^\s*-p\b.*prompt", out):
            return "-p"
        return None

    def transcribe(self, audio_path: str) -> str:
        whisper_bin = shutil.which("whisper-cli") or shutil.which("whisper-cpp") or shutil.which("main")
        if not whisper_bin:
            candidates = [
                MODELS_DIR / "whisper.cpp" / "build" / "bin" / "whisper-cli",
                MODELS_DIR / "whisper.cpp" / "build" / "bin" / "main",
                MODELS_DIR / "whisper.cpp" / "main",
            ]
            for c in candidates:
                if c.exists():
                    whisper_bin = str(c)
                    break

        model_name = self.model or "base"
        if model_name == "large":
            p2 = MODELS_DIR / "whisper.cpp" / "ggml-large-v3.bin"
            p1 = MODELS_DIR / "whisper.cpp" / "ggml-large.bin"
            model_path = p2 if p2.exists() else p1
        else:
            model_path = MODELS_DIR / "whisper.cpp" / f"ggml-{model_name}.bin"
        if not model_path.exists():
            model_path = self.download_model(model_name)

        if not model_path:
            return ""

        try:
            short_audio = self._is_short_audio(audio_path)

            lang = str(self.language or "").strip()
            if lang.lower() in ("", "auto"):
                cmd = [whisper_bin, "-m", str(model_path), "-f", audio_path, "-nt"]
            else:
                cmd = [whisper_bin, "-m", str(model_path), "-l", lang, "-f", audio_path, "-nt"]

            threads = (
                (self.config.get("stt_threads") if isinstance(self.config, dict) else None)
                or os.environ.get("STTS_STT_THREADS", "")
            )
            try:
                threads_i = int(str(threads).strip()) if str(threads).strip() else 0
            except Exception:
                threads_i = 0
            if threads_i <= 0:
                threads_i = 4 if short_audio else min(os.cpu_count() or 4, 8)
            cmd.extend(["-t", str(threads_i)])

            max_len = (
                (self.config.get("stt_whisper_max_len") if isinstance(self.config, dict) else None)
                or os.environ.get("STTS_WHISPER_MAX_LEN", "")
            )
            try:
                max_len_i = int(str(max_len).strip()) if str(max_len).strip() else 0
            except Exception:
                max_len_i = 0
            if max_len_i <= 0 and short_audio:
                max_len_i = 72
            if max_len_i > 0:
                if self._supports_help_token(str(whisper_bin), "-ml"):
                    cmd.extend(["-ml", str(max_len_i)])
                elif self._supports_help_token(str(whisper_bin), "--max-len"):
                    cmd.extend(["--max-len", str(max_len_i)])

            word_thold = (
                (self.config.get("stt_whisper_word_thold") if isinstance(self.config, dict) else None)
                or os.environ.get("STTS_WHISPER_WORD_THOLD", "")
            )
            try:
                wt = float(str(word_thold).strip()) if str(word_thold).strip() else None
            except Exception:
                wt = None
            if wt is None and short_audio:
                wt = 0.01
            if wt is not None:
                if self._supports_help_token(str(whisper_bin), "-wt"):
                    cmd.extend(["-wt", str(wt)])
                elif self._supports_help_token(str(whisper_bin), "--word-thold"):
                    cmd.extend(["--word-thold", str(wt)])

            no_speech_thold = (
                (self.config.get("stt_whisper_no_speech_thold") if isinstance(self.config, dict) else None)
                or os.environ.get("STTS_WHISPER_NO_SPEECH_THOLD", "")
            )
            try:
                nth = float(str(no_speech_thold).strip()) if str(no_speech_thold).strip() else None
            except Exception:
                nth = None
            if nth is None and short_audio:
                nth = 0.8
            if nth is not None:
                if self._supports_help_token(str(whisper_bin), "-nth"):
                    cmd.extend(["-nth", str(nth)])
                elif self._supports_help_token(str(whisper_bin), "--no-speech-thold"):
                    cmd.extend(["--no-speech-thold", str(nth)])

            entropy_thold = (
                (self.config.get("stt_whisper_entropy_thold") if isinstance(self.config, dict) else None)
                or os.environ.get("STTS_WHISPER_ENTROPY_THOLD", "")
            )
            try:
                et = float(str(entropy_thold).strip()) if str(entropy_thold).strip() else None
            except Exception:
                et = None
            if et is not None:
                if self._supports_help_token(str(whisper_bin), "-et"):
                    cmd.extend(["-et", str(et)])
                elif self._supports_help_token(str(whisper_bin), "--entropy-thold"):
                    cmd.extend(["--entropy-thold", str(et)])

            best_of = (
                (self.config.get("stt_whisper_best_of") if isinstance(self.config, dict) else None)
                or os.environ.get("STTS_WHISPER_BEST_OF", "")
            )
            try:
                bo = int(str(best_of).strip()) if str(best_of).strip() else None
            except Exception:
                bo = None
            if bo is None and short_audio:
                bo = 1
            if bo is not None:
                if self._supports_help_token(str(whisper_bin), "-bo"):
                    cmd.extend(["-bo", str(bo)])
                elif self._supports_help_token(str(whisper_bin), "--best-of"):
                    cmd.extend(["--best-of", str(bo)])

            temperature = (
                (self.config.get("stt_whisper_temperature") if isinstance(self.config, dict) else None)
                or os.environ.get("STTS_WHISPER_TEMPERATURE", "")
            )
            try:
                tp = float(str(temperature).strip()) if str(temperature).strip() else None
            except Exception:
                tp = None
            if tp is None and short_audio:
                tp = 0.0
            if tp is not None:
                if self._supports_help_token(str(whisper_bin), "-tp"):
                    cmd.extend(["-tp", str(tp)])
                elif self._supports_help_token(str(whisper_bin), "--temperature"):
                    cmd.extend(["--temperature", str(tp)])

            prompt = (self.config.get("stt_prompt") if isinstance(self.config, dict) else None) or os.environ.get("STTS_STT_PROMPT", "")
            prompt = str(prompt or "").strip()
            if prompt:
                flag = self._detect_prompt_flag(str(whisper_bin))
                if flag in ("--prompt", "-p"):
                    cmd.extend([flag, prompt])

            gpu_layers = 0
            try:
                gpu_layers = int(self.config.get("stt_gpu_layers", 0) or 0)
            except Exception:
                gpu_layers = 0
            if gpu_layers <= 0:
                try:
                    gpu_layers = int(os.environ.get("STTS_STT_GPU_LAYERS", "0").strip() or "0")
                except Exception:
                    gpu_layers = 0
            if gpu_layers <= 0:
                # Backward compatibility
                try:
                    gpu_layers = int(os.environ.get("STTS_GPU_LAYERS", "0").strip() or "0")
                except Exception:
                    gpu_layers = 0

            if gpu_layers > 0 and self._has_gpu_build():
                cmd.extend(["-ngl", str(gpu_layers)])

            result = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                timeout=120,
            )
            raw_text = result.stdout.strip()
            return TextNormalizer.normalize(raw_text, self.language)
        except Exception as e:
            cprint(Colors.RED, f"❌ Transcription error: {e}")
            return ""


class DeepgramSTT(STTProvider):
    name = "deepgram"
    description = "Online STT (Deepgram REST)"
    min_ram_gb = 0.1

    @classmethod
    def is_available(cls, info: SystemInfo):
        key = os.environ.get("STTS_DEEPGRAM_KEY", "").strip()
        if not key:
            return False, "set STTS_DEEPGRAM_KEY"
        return True, "Deepgram key set"

    @classmethod
    def get_recommended_model(cls, info: SystemInfo) -> Optional[str]:
        return "nova-2"

    def transcribe(self, audio_path: str) -> str:
        key = os.environ.get("STTS_DEEPGRAM_KEY", "").strip()
        if not key:
            return ""

        model = (
            (self.config.get("stt_model") if isinstance(self.config, dict) else None)
            or os.environ.get("STTS_DEEPGRAM_MODEL", "")
            or "nova-2"
        )
        model = str(model).strip() or "nova-2"

        language = (
            (self.language or "pl")
            if str(self.language or "").strip()
            else (os.environ.get("STTS_LANGUAGE", "pl") or "pl")
        )
        language = str(language).strip() or "pl"

        try:
            data = Path(audio_path).read_bytes()
        except Exception:
            return ""

        params = {
            "model": model,
            "language": language,
            "smart_format": "true",
        }
        url = "https://api.deepgram.com/v1/listen?" + urllib.parse.urlencode(params)
        req = urllib.request.Request(
            url,
            data=data,
            method="POST",
            headers={
                "Authorization": f"Token {key}",
                "Content-Type": "audio/wav",
            },
        )

        try:
            with urllib.request.urlopen(req, timeout=120) as resp:
                payload = resp.read().decode("utf-8", errors="replace")
        except Exception as e:
            cprint(Colors.RED, f"❌ Deepgram error: {e}")
            return ""

        try:
            j = json.loads(payload)
            txt = (
                (((j.get("results") or {}).get("channels") or [None])[0] or {}).get("alternatives") or [None]
            )[0]
            if isinstance(txt, dict):
                transcript = (txt.get("transcript") or "").strip()
            else:
                transcript = ""
        except Exception:
            transcript = ""

        return TextNormalizer.normalize(transcript, self.language)


class VoskSTT(STTProvider):
    name = "vosk"
    description = "Offline, fast, lightweight STT (good for RPi)"
    min_ram_gb = 0.5
    models = [
        ("small-pl", "vosk-model-small-pl-0.22", 0.05),
        ("pl", "vosk-model-small-pl-0.22", 0.05),
        ("pl-full", "vosk-model-pl-0.22", 0.2),
        ("pl-0.22", "vosk-model-pl-0.22", 0.2),
        ("small-en", "vosk-model-small-en-us-0.15", 0.04),
    ]

    @classmethod
    def is_available(cls, info: SystemInfo):
        try:
            import vosk
            vosk.SetLogLevel(-1)
        except ImportError:
            return False, "pip install vosk"
        # Check if any model exists
        vosk_dir = MODELS_DIR / "vosk"
        if vosk_dir.exists():
            models = list(vosk_dir.glob("vosk-model-*"))
            if models:
                return True, f"vosk + {len(models)} model(s)"
        return False, "vosk installed, no models (make stt-vosk-pl)"

    @classmethod
    def install(cls, info: SystemInfo) -> bool:
        try:
            res = subprocess.run(
                [sys.executable, "-m", "pip", "install", "-U", "vosk"],
                capture_output=True,
                text=True,
                timeout=300,
            )
            if res.returncode == 0:
                return True
        except Exception:
            pass

        try:
            res = subprocess.run(
                [sys.executable, "-m", "pip", "install", "-U", "--user", "vosk"],
                capture_output=True,
                text=True,
                timeout=300,
            )
            if res.returncode == 0:
                return True
        except Exception:
            pass
        return False

    @classmethod
    def download_model(cls, model_name: str) -> Optional[Path]:
        name = (model_name or "").strip() or "small-pl"
        if name in ("pl", "pl_PL"):
            name = "small-pl"
        if name in ("pl-full", "pl_full"):
            name = "pl-0.22"

        url = None
        out_dir = MODELS_DIR / "vosk"
        out_dir.mkdir(parents=True, exist_ok=True)

        if name in ("small-pl", "pl-small"):
            url = "https://alphacephei.com/vosk/models/vosk-model-small-pl-0.22.zip"
        elif name in ("pl-0.22", "pl"):
            url = "https://alphacephei.com/vosk/models/vosk-model-pl-0.22.zip"
        elif name in ("small-en", "en"):
            url = "https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip"
        else:
            mappings = {
                "vosk-model-small-pl-0.22": "https://alphacephei.com/vosk/models/vosk-model-small-pl-0.22.zip",
                "vosk-model-pl-0.22": "https://alphacephei.com/vosk/models/vosk-model-pl-0.22.zip",
                "vosk-model-small-en-us-0.15": "https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip",
            }
            url = mappings.get(name)

        if not url:
            return None

        tmp_path = None
        try:
            with tempfile.NamedTemporaryFile(prefix="stts_vosk_", suffix=".zip", delete=False) as f:
                tmp_path = f.name
            urllib.request.urlretrieve(url, tmp_path, _download_progress)
            print()

            with zipfile.ZipFile(tmp_path, "r") as z:
                for member in z.infolist():
                    p = member.filename
                    if p.startswith("/") or ".." in p.split("/"):
                        continue
                    z.extract(member, path=str(out_dir))

            # Return the first extracted model dir
            extracted = list(out_dir.glob("vosk-model-*/"))
            if extracted:
                return extracted[0]
            extracted2 = list(out_dir.glob("vosk-model-*"))
            if extracted2:
                return extracted2[0]
        except Exception:
            return None
        finally:
            if tmp_path:
                try:
                    os.unlink(tmp_path)
                except Exception:
                    pass
        return None

    @classmethod
    def get_recommended_model(cls, info: SystemInfo) -> Optional[str]:
        return "small-pl"

    def _find_model_path(self) -> Optional[Path]:
        """Find vosk model directory."""
        vosk_dir = MODELS_DIR / "vosk"
        if not vosk_dir.exists():
            return None

        # If model is a full path
        if self.model and Path(self.model).exists():
            return Path(self.model)

        # Map short names to directory names
        model_name = self.model or "small-pl"
        mappings = {
            "small-pl": "vosk-model-small-pl-0.22",
            "pl": "vosk-model-small-pl-0.22",
            "pl-small": "vosk-model-small-pl-0.22",
            "pl-full": "vosk-model-pl-0.22",
            "pl-0.22": "vosk-model-pl-0.22",
            "small-en": "vosk-model-small-en-us-0.15",
        }
        dir_name = mappings.get(model_name, model_name)

        # Try exact match
        model_path = vosk_dir / dir_name
        if model_path.exists():
            return model_path

        # Try glob
        matches = list(vosk_dir.glob(f"*{model_name}*"))
        if matches:
            return matches[0]

        # Fallback to first available
        models = list(vosk_dir.glob("vosk-model-*"))
        if models:
            return models[0]

        return None

    def transcribe(self, audio_path: str) -> str:
        try:
            import vosk
            vosk.SetLogLevel(-1)
        except ImportError:
            cprint(Colors.RED, "❌ vosk not installed: pip install vosk")
            return ""

        model_path = self._find_model_path()
        if not model_path:
            cprint(Colors.RED, "❌ Vosk model not found. Run: make stt-vosk-pl")
            return ""

        try:
            import wave
            model = vosk.Model(str(model_path))

            def _decode_with_grammar(grammar: str) -> Tuple[str, str]:
                wf = wave.open(audio_path, "rb")
                if wf.getnchannels() != 1 or wf.getsampwidth() != 2:
                    wf.close()
                    cprint(Colors.YELLOW, "⚠️ Audio must be mono 16-bit WAV")
                    return "", ""

                def _run_recognizer(r) -> Tuple[str, str]:
                    while True:
                        data = wf.readframes(4000)
                        if len(data) == 0:
                            break
                        r.AcceptWaveform(data)
                    fj = r.FinalResult()
                    try:
                        jj = json.loads(fj)
                        tt = (jj.get("text") or "").strip()
                    except Exception:
                        tt = ""
                    return fj, tt

                if grammar:
                    try:
                        rec = vosk.KaldiRecognizer(model, wf.getframerate(), grammar)
                    except TypeError:
                        rec = vosk.KaldiRecognizer(model, wf.getframerate())
                        try:
                            rec.SetGrammar(grammar)
                        except Exception:
                            pass
                else:
                    rec = vosk.KaldiRecognizer(model, wf.getframerate())
                rec.SetWords(False)

                final_json, transcript = _run_recognizer(rec)
                if (not transcript) and grammar:
                    try:
                        wf.rewind()
                        rec2 = vosk.KaldiRecognizer(model, wf.getframerate())
                        rec2.SetWords(False)
                        final_json2, transcript2 = _run_recognizer(rec2)
                        if transcript2:
                            cprint(Colors.YELLOW, "⚠️ Vosk: grammar returned empty, retry without grammar")
                            final_json = final_json2
                            transcript = transcript2
                    except Exception:
                        pass

                wf.close()
                return transcript, final_json

            grammar_src = (
                (self.config.get("stt_vosk_grammar") if isinstance(self.config, dict) else None)
                or os.environ.get("STTS_VOSK_GRAMMAR_JSON", "")
            )
            grammar_src = str(grammar_src or "").strip()
            if grammar_src and Path(grammar_src).exists():
                try:
                    grammar_src = Path(grammar_src).read_text(encoding="utf-8")
                except Exception:
                    grammar_src = ""
            grammar_json = ""
            if grammar_src:
                try:
                    j = json.loads(grammar_src)
                    if isinstance(j, list):
                        grammar_json = json.dumps(j)
                    elif isinstance(j, dict):
                        phrases: List[str] = []
                        for v in j.values():
                            if not isinstance(v, list):
                                continue
                            for alt in v:
                                if isinstance(alt, list):
                                    phrase = " ".join(str(x).strip() for x in alt if str(x).strip())
                                    if phrase:
                                        phrases.append(phrase)
                                elif isinstance(alt, str) and alt.strip():
                                    phrases.append(alt.strip())
                        if phrases:
                            grammar_json = json.dumps(sorted(set(phrases)))
                except Exception:
                    grammar_json = ""

            transcript, final_json = _decode_with_grammar(grammar_json)
            if (not transcript) and grammar_json:
                transcript, final_json = _decode_with_grammar("")

            debug = (os.environ.get("STTS_DEBUG_STT") == "1") or (os.environ.get("STTS_DEBUG_VOSK") == "1")
            if debug and (not transcript):
                try:
                    cprint(
                        Colors.MAGENTA,
                        f"[stts] vosk empty result: model={model_path}",
                    )
                    cprint(Colors.MAGENTA, f"[stts] vosk FinalResult: {final_json}")
                except Exception:
                    pass

            return TextNormalizer.normalize(transcript, self.language)

        except Exception as e:
            cprint(Colors.RED, f"❌ Vosk error: {e}")
            return ""


class TTSProvider:
    name: str = "base"

    @classmethod
    def is_available(cls, info: SystemInfo):
        return False, "Not implemented"

    @classmethod
    def install(cls, info: SystemInfo) -> bool:
        return False

    def __init__(self, voice: str = "pl", config: Optional[dict] = None, info: Optional[SystemInfo] = None):
        self.voice = voice
        self.config = config or {}
        self.info = info

    def speak(self, text: str) -> None:
        raise NotImplementedError


class EspeakTTS(TTSProvider):
    name = "espeak"

    @classmethod
    def is_available(cls, info: SystemInfo):
        if shutil.which("espeak") or shutil.which("espeak-ng"):
            return True, "espeak found"
        return False, "apt install espeak / espeak-ng"

    def speak(self, text: str) -> None:
        cmd = shutil.which("espeak-ng") or shutil.which("espeak")
        if not cmd:
            cprint(Colors.YELLOW, "⚠️  Brak espeak/espeak-ng")
            return
        try:
            no_play = os.environ.get("STTS_TTS_NO_PLAY", "").strip().lower() in ("1", "true", "yes", "y")
            if no_play:
                with tempfile.NamedTemporaryFile(prefix="stts_espeak_", suffix=".wav", delete=False) as f:
                    out_path = f.name
                res = subprocess.run(
                    [cmd, "-v", self.voice, "-s", "160", "-w", out_path, text],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                )
                try:
                    Path(out_path).unlink(missing_ok=True)
                except Exception:
                    pass
            else:
                res = subprocess.run(
                    [cmd, "-v", self.voice, "-s", "160", text],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                )
            if getattr(res, "returncode", 0) != 0:
                cprint(Colors.YELLOW, f"⚠️  espeak returncode={res.returncode}")
        except Exception:
            return


class PiperTTS(TTSProvider):
    name = "piper"

    @staticmethod
    def find_piper_bin() -> Optional[str]:
        p = shutil.which("piper")
        if p:
            return p
        for cand in (BIN_DIR / "piper", BIN_DIR / "piper" / "piper"):
            try:
                if cand.exists() and cand.is_file():
                    return str(cand)
            except Exception:
                continue
        return None

    @staticmethod
    def _piper_asset_name(info: SystemInfo) -> Optional[str]:
        if info.os_name != "linux":
            return None
        arch = (info.arch or "").lower()
        if arch in ("x86_64", "amd64"):
            return "piper_linux_x86_64.tar.gz"
        if arch in ("aarch64", "arm64"):
            return "piper_linux_aarch64.tar.gz"
        if arch in ("armv7l",):
            return "piper_linux_armv7l.tar.gz"
        return None

    @staticmethod
    def install_local(info: SystemInfo, release_tag: str) -> bool:
        asset = PiperTTS._piper_asset_name(info)
        if not asset:
            cprint(Colors.YELLOW, f"⚠️  Nieobsługiwana platforma dla piper: os={info.os_name} arch={info.arch}")
            return False
        url = f"https://github.com/rhasspy/piper/releases/download/{release_tag}/{asset}"
        BIN_DIR.mkdir(parents=True, exist_ok=True)

        try:
            with tempfile.NamedTemporaryFile(prefix="stts_piper_", suffix=".tar.gz", delete=False) as f:
                tmp_path = f.name
            cprint(Colors.YELLOW, f"📥 Downloading piper binary: {asset}")
            urllib.request.urlretrieve(url, tmp_path, _download_progress)
            print()

            def _safe_members(tar: tarfile.TarFile):
                for m in tar.getmembers():
                    p = m.name
                    if p.startswith("/") or ".." in p.split("/"):
                        continue
                    yield m

            with tarfile.open(tmp_path, "r:gz") as tar:
                tar.extractall(path=str(BIN_DIR), members=list(_safe_members(tar)))

            piper_bin = PiperTTS.find_piper_bin()
            if not piper_bin:
                cprint(Colors.YELLOW, "⚠️  piper pobrany, ale nie znaleziono binarki")
                return False
            try:
                os.chmod(piper_bin, 0o755)
            except Exception:
                pass
            cprint(Colors.GREEN, f"✅ Piper installed: {piper_bin}")
            return True
        except Exception as e:
            cprint(Colors.YELLOW, f"⚠️  Piper install failed: {e}")
            return False
        finally:
            try:
                if "tmp_path" in locals() and tmp_path and Path(tmp_path).exists():
                    Path(tmp_path).unlink()
            except Exception:
                pass

    @staticmethod
    def _parse_voice_id(voice_id: str) -> Optional[Tuple[str, str, str, str]]:
        v = (voice_id or "").strip()
        if not v or "/" in v or v.endswith(".onnx"):
            return None
        parts = v.split("-")
        if len(parts) < 3:
            return None
        locale = parts[0]
        quality = parts[-1]
        speaker = "-".join(parts[1:-1])
        lang = locale.split("_")[0].lower() if "_" in locale else locale.lower()
        return lang, locale, speaker, quality

    @staticmethod
    def download_voice(voice_id: str, voice_version: str) -> bool:
        parsed = PiperTTS._parse_voice_id(voice_id)
        if not parsed:
            cprint(Colors.YELLOW, f"⚠️  Niepoprawny voice id dla piper: {voice_id}")
            return False
        lang, locale, speaker, quality = parsed
        base = f"https://huggingface.co/rhasspy/piper-voices/resolve/{voice_version}"
        model_url = f"{base}/{lang}/{locale}/{speaker}/{quality}/{voice_id}.onnx?download=true"
        cfg_url = f"{base}/{lang}/{locale}/{speaker}/{quality}/{voice_id}.onnx.json?download=true"

        out_dir = MODELS_DIR / "piper"
        out_dir.mkdir(parents=True, exist_ok=True)
        model_path = out_dir / f"{voice_id}.onnx"
        cfg_path = out_dir / f"{voice_id}.onnx.json"

        try:
            if not model_path.exists() or model_path.stat().st_size < 1024 * 1024:
                cprint(Colors.YELLOW, f"📥 Downloading piper voice model: {voice_id}")
                urllib.request.urlretrieve(model_url, str(model_path), _download_progress)
                print()
            if not cfg_path.exists() or cfg_path.stat().st_size < 100:
                cprint(Colors.YELLOW, f"📥 Downloading piper voice config: {voice_id}")
                urllib.request.urlretrieve(cfg_url, str(cfg_path), _download_progress)
                print()
            cprint(Colors.GREEN, f"✅ Piper voice ready: {model_path}")
            return True
        except Exception as e:
            cprint(Colors.YELLOW, f"⚠️  Piper voice download failed: {e}")
            return False

    @classmethod
    def is_available(cls, info: SystemInfo):
        if PiperTTS.find_piper_bin():
            return True, "piper found"
        return False, "install piper (binary)"

    def _resolve_model(self) -> Optional[str]:
        v = (self.voice or "").strip()
        if not v:
            return None

        # Map short language codes to full piper voice names
        VOICE_ALIASES = {
            "pl": "pl_PL-gosia-medium",
            "en": "en_US-amy-medium",
            "de": "de_DE-thorsten-medium",
            "fr": "fr_FR-upmc-medium",
            "es": "es_ES-carlfm-medium",
        }
        if v in VOICE_ALIASES:
            v = VOICE_ALIASES[v]

        p = Path(v).expanduser()
        if p.exists() and p.is_file():
            cfg = Path(str(p) + ".json")
            if not cfg.exists():
                cprint(Colors.YELLOW, f"⚠️  Brak pliku config dla piper: {cfg}")
                return None
            return str(p)
        p2 = MODELS_DIR / "piper" / f"{v}.onnx"
        if p2.exists():
            cfg = Path(str(p2) + ".json")
            if not cfg.exists():
                cprint(Colors.YELLOW, f"⚠️  Brak pliku config dla piper: {cfg}")
                return None
            return str(p2)
        return None

    def _play_wav(self, wav_path: str) -> None:
        if os.environ.get("STTS_TTS_NO_PLAY", "").strip().lower() in ("1", "true", "yes", "y"):
            return
        player = shutil.which("paplay") or shutil.which("aplay") or shutil.which("play")
        if not player:
            cprint(Colors.YELLOW, "⚠️  Brak odtwarzacza audio (paplay/aplay/play)")
            return
        try:
            if Path(player).name == "play":
                res = subprocess.run([player, "-q", wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            else:
                res = subprocess.run([player, wav_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            if getattr(res, "returncode", 0) != 0:
                cprint(Colors.YELLOW, f"⚠️  audio player returncode={res.returncode}")
        except Exception:
            return

    def speak(self, text: str) -> None:
        piper = PiperTTS.find_piper_bin()
        if not piper and self.config.get("piper_auto_install", True):
            try:
                info = self.info or detect_system(fast=True)
                PiperTTS.install_local(info, self.config.get("piper_release_tag", "2023.11.14-2"))
                piper = PiperTTS.find_piper_bin()
            except Exception:
                piper = PiperTTS.find_piper_bin()

        model = self._resolve_model()
        if not model and self.config.get("piper_auto_download", True):
            try:
                PiperTTS.download_voice(self.voice, self.config.get("piper_voice_version", "v1.0.0"))
            except Exception:
                pass
            model = self._resolve_model()

        if not piper:
            cprint(Colors.YELLOW, "⚠️  Brak binarki piper w PATH")
            return
        if not model:
            cprint(Colors.YELLOW, "⚠️  Piper model nie ustawiony. Ustaw tts_voice na ścieżkę do .onnx lub nazwę z ~/.config/stts-python/models/piper/")
            return
        try:
            with tempfile.NamedTemporaryFile(prefix="stts_piper_", suffix=".wav", delete=False) as f:
                out_path = f.name
            res = subprocess.run(
                [piper, "--model", model, "--output_file", out_path],
                input=text,
                text=True,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                timeout=60,
            )
            if getattr(res, "returncode", 0) != 0:
                cprint(Colors.YELLOW, f"⚠️  piper returncode={res.returncode}")
            self._play_wav(out_path)
            try:
                Path(out_path).unlink(missing_ok=True)
            except Exception:
                pass
        except Exception:
            return


class SpdSayTTS(TTSProvider):
    name = "spd-say"

    @classmethod
    def is_available(cls, info: SystemInfo):
        if shutil.which("spd-say"):
            return True, "spd-say found"
        return False, "install speech-dispatcher (spd-say)"

    def speak(self, text: str) -> None:
        cmd = shutil.which("spd-say")
        if not cmd:
            return
        try:
            subprocess.run([cmd, "-l", str(self.voice), text], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        except Exception:
            return


class SayTTS(TTSProvider):
    name = "say"

    @classmethod
    def is_available(cls, info: SystemInfo):
        if info.os_name == "darwin" and shutil.which("say"):
            return True, "say found"
        return False, "macOS only (say)"

    def speak(self, text: str) -> None:
        cmd = shutil.which("say")
        if not cmd:
            return
        try:
            args = [cmd]
            if self.voice:
                args += ["-v", str(self.voice)]
            args.append(text)
            subprocess.run(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        except Exception:
            return


class FliteTTS(TTSProvider):
    name = "flite"

    @classmethod
    def is_available(cls, info: SystemInfo):
        if shutil.which("flite"):
            return True, "flite found"
        return False, "install flite"

    def speak(self, text: str) -> None:
        cmd = shutil.which("flite")
        if not cmd:
            return
        try:
            args = [cmd]
            if self.voice:
                args += ["-voice", str(self.voice)]
            args += ["-t", text]
            subprocess.run(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        except Exception:
            return


class CoquiSTT(STTProvider):
    """Coqui STT - lightweight, CPU-friendly, good for Polish accents."""
    name = "coqui"
    description = "Offline, lightweight STT (Coqui/DeepSpeech)"
    min_ram_gb = 0.5

    @classmethod
    def is_available(cls, info: SystemInfo):
        try:
            from stt import Model
            return True, "coqui-stt installed"
        except ImportError:
            return False, "pip install coqui-stt"

    @classmethod
    def get_recommended_model(cls, info: SystemInfo) -> Optional[str]:
        return "model.tflite"

    def transcribe(self, audio_path: str) -> str:
        try:
            from stt import Model
        except ImportError:
            cprint(Colors.RED, "❌ coqui-stt not installed: pip install coqui-stt")
            return ""

        model_path = self.model or str(MODELS_DIR / "coqui" / "model.tflite")
        if not Path(model_path).exists():
            cprint(Colors.RED, f"❌ Coqui model not found: {model_path}")
            return ""

        try:
            import wave
            model = Model(model_path)

            wf = wave.open(audio_path, "rb")
            audio = wf.readframes(wf.getnframes())
            wf.close()

            text = model.stt(audio)
            return TextNormalizer.normalize(text or "", self.language)
        except Exception as e:
            cprint(Colors.RED, f"❌ Coqui STT error: {e}")
            return ""


class PicovoiceSTT(STTProvider):
    """Picovoice Leopard - ultra-lightweight STT for embedded."""
    name = "picovoice"
    description = "Offline, ultra-light STT (~5MB, Pi Zero compatible)"
    min_ram_gb = 0.1

    @classmethod
    def is_available(cls, info: SystemInfo):
        try:
            import pvleopard
            return True, "pvleopard installed"
        except ImportError:
            return False, "pip install pvleopard"

    @classmethod
    def get_recommended_model(cls, info: SystemInfo) -> Optional[str]:
        return None

    def transcribe(self, audio_path: str) -> str:
        try:
            import pvleopard
        except ImportError:
            cprint(Colors.RED, "❌ pvleopard not installed: pip install pvleopard")
            return ""

        access_key = os.environ.get("PICOVOICE_ACCESS_KEY", "").strip()
        if not access_key:
            cprint(Colors.RED, "❌ PICOVOICE_ACCESS_KEY not set")
            return ""

        try:
            leopard = pvleopard.create(access_key=access_key)
            transcript, _ = leopard.process_file(audio_path)
            leopard.delete()
            return TextNormalizer.normalize(transcript or "", self.language)
        except Exception as e:
            cprint(Colors.RED, f"❌ Picovoice STT error: {e}")
            return ""


class CoquiTTS(TTSProvider):
    """Coqui TTS - neural TTS, XTTS-v2 multilingual."""
    name = "coqui-tts"

    @classmethod
    def is_available(cls, info: SystemInfo):
        try:
            from TTS.api import TTS
            return True, "coqui-tts installed"
        except ImportError:
            return False, "pip install TTS"

    def speak(self, text: str) -> None:
        try:
            from TTS.api import TTS
        except ImportError:
            cprint(Colors.YELLOW, "⚠️ coqui-tts not installed: pip install TTS")
            return

        no_play = os.environ.get("STTS_TTS_NO_PLAY", "").strip().lower() in ("1", "true", "yes", "y")

        try:
            model_name = self.config.get("coqui_tts_model") or os.environ.get("STTS_COQUI_TTS_MODEL", "tts_models/multilingual/multi-dataset/xtts_v2")
            tts = TTS(model_name)

            with tempfile.NamedTemporaryFile(prefix="stts_coqui_", suffix=".wav", delete=False) as f:
                out_path = f.name

            tts.tts_to_file(text=text, file_path=out_path, language=self.voice or "pl")

            if not no_play:
                play_audio(out_path)

            try:
                Path(out_path).unlink(missing_ok=True)
            except Exception:
                pass
        except Exception as e:
            cprint(Colors.YELLOW, f"⚠️ Coqui TTS error: {e}")


class FestivalTTS(TTSProvider):
    """Festival TTS - classic, ultra-lightweight."""
    name = "festival"

    @classmethod
    def is_available(cls, info: SystemInfo):
        if shutil.which("text2wave") and shutil.which("aplay"):
            return True, "festival found"
        if shutil.which("festival"):
            return True, "festival found"
        return False, "apt install festival festvox-kallpc16k"

    def speak(self, text: str) -> None:
        no_play = os.environ.get("STTS_TTS_NO_PLAY", "").strip().lower() in ("1", "true", "yes", "y")

        try:
            with tempfile.NamedTemporaryFile(prefix="stts_festival_", suffix=".wav", delete=False) as f:
                out_path = f.name

            # Use text2wave if available
            if shutil.which("text2wave"):
                proc = subprocess.run(
                    ["text2wave", "-o", out_path],
                    input=text,
                    text=True,
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                )
            else:
                # Fallback to festival --tts
                proc = subprocess.run(
                    ["festival", "--tts"],
                    input=text,
                    text=True,
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                )
                return  # festival --tts plays directly

            if not no_play and Path(out_path).exists():
                play_audio(out_path)

            try:
                Path(out_path).unlink(missing_ok=True)
            except Exception:
                pass
        except Exception as e:
            cprint(Colors.YELLOW, f"⚠️ Festival TTS error: {e}")


class RHVoiceTTS(TTSProvider):
    """RHVoice - native Polish TTS, fast CPU."""
    name = "rhvoice"

    @classmethod
    def is_available(cls, info: SystemInfo):
        if shutil.which("RHVoice-test") or shutil.which("rhvoice-test"):
            return True, "rhvoice found"
        return False, "apt install rhvoice rhvoice-polish"

    def speak(self, text: str) -> None:
        cmd = shutil.which("RHVoice-test") or shutil.which("rhvoice-test")
        if not cmd:
            cprint(Colors.YELLOW, "⚠️ RHVoice not found")
            return

        no_play = os.environ.get("STTS_TTS_NO_PLAY", "").strip().lower() in ("1", "true", "yes", "y")

        try:
            voice = self.voice or "Anna"  # Polish voice

            with tempfile.NamedTemporaryFile(prefix="stts_rhvoice_", suffix=".wav", delete=False) as f:
                out_path = f.name

            proc = subprocess.run(
                [cmd, "-p", voice, "-o", out_path],
                input=text,
                text=True,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )

            if not no_play and Path(out_path).exists():
                play_audio(out_path)

            try:
                Path(out_path).unlink(missing_ok=True)
            except Exception:
                pass
        except Exception as e:
            cprint(Colors.YELLOW, f"⚠️ RHVoice error: {e}")


class KokoroTTS(TTSProvider):
    """Kokoro-82M - new open-source, fast on CPU."""
    name = "kokoro"

    @classmethod
    def is_available(cls, info: SystemInfo):
        try:
            import kokoro
            return True, "kokoro installed"
        except ImportError:
            return False, "pip install kokoro"

    def speak(self, text: str) -> None:
        try:
            import kokoro
        except ImportError:
            cprint(Colors.YELLOW, "⚠️ kokoro not installed: pip install kokoro")
            return

        no_play = os.environ.get("STTS_TTS_NO_PLAY", "").strip().lower() in ("1", "true", "yes", "y")

        try:
            with tempfile.NamedTemporaryFile(prefix="stts_kokoro_", suffix=".wav", delete=False) as f:
                out_path = f.name

            # Kokoro API (simplified)
            model = kokoro.KokoroTTS()
            audio = model.generate(text)
            import scipy.io.wavfile as wav
            wav.write(out_path, 24000, audio)

            if not no_play:
                play_audio(out_path)

            try:
                Path(out_path).unlink(missing_ok=True)
            except Exception:
                pass
        except Exception as e:
            cprint(Colors.YELLOW, f"⚠️ Kokoro TTS error: {e}")


STT_PROVIDERS = {
    "whisper_cpp": WhisperCppSTT,
    "deepgram": DeepgramSTT,
    "vosk": VoskSTT,
    "coqui": CoquiSTT,
    "picovoice": PicovoiceSTT,
}
TTS_PROVIDERS = {
    "espeak": EspeakTTS,
    "piper": PiperTTS,
    "spd-say": SpdSayTTS,
    "say": SayTTS,
    "flite": FliteTTS,
    "coqui-tts": CoquiTTS,
    "festival": FestivalTTS,
    "rhvoice": RHVoiceTTS,
    "kokoro": KokoroTTS,
}


def _ts() -> str:
    """Return current timestamp for logging."""
    return time.strftime("%H:%M:%S")


def record_audio_vad(
    max_duration: float = 5.0,
    output_path: str = "/tmp/stts_audio.wav",
    device: Optional[str] = None,
    silence_ms: int = 800,
    threshold_db: float = -45.0,
    rate: int = 16000,
) -> str:
    """Record with VAD: stop early after silence_ms of silence below threshold_db."""
    t0 = time.perf_counter()
    cprint(Colors.GREEN, f"[{_ts()}] 🎤 Mów (max {max_duration:.0f}s, VAD)...", end=" ")

    cmd = ["arecord"]
    if device:
        cmd += ["-D", device]
    cmd += ["-q", "-r", str(rate), "-c", "1", "-f", "S16_LE", "-t", "raw", "-"]

    try:
        proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
    except Exception as e:
        cprint(Colors.RED, f"❌ arecord error: {e}")
        return ""

    chunk_samples = int(rate * 0.1)  # 100ms chunks
    chunk_bytes = chunk_samples * 2
    silence_samples_needed = int(silence_ms / 100)
    max_samples = int(max_duration * 10)

    all_audio = bytearray()
    silence_count = 0
    speech_detected = False
    sample_count = 0

    try:
        while sample_count < max_samples:
            chunk = proc.stdout.read(chunk_bytes)
            if not chunk:
                break
            all_audio.extend(chunk)
            sample_count += 1

            # Calculate RMS for this chunk
            n = len(chunk) // 2
            if n > 0:
                samples = struct.unpack("<" + "h" * n, chunk[:n * 2])
                mean_sq = sum(float(s) * float(s) for s in samples) / float(n)
                rms = math.sqrt(mean_sq)
                db = 20.0 * math.log10(rms / 32767.0) if rms > 0 else -120.0

                if db > threshold_db:
                    speech_detected = True
                    silence_count = 0
                else:
                    silence_count += 1

                # Stop if we had speech and now silence for silence_ms
                if speech_detected and silence_count >= silence_samples_needed:
                    break
    finally:
        proc.terminate()
        try:
            proc.wait(timeout=1)
        except Exception:
            proc.kill()

    elapsed = time.perf_counter() - t0

    if not all_audio:
        cprint(Colors.RED, f"❌ Brak danych audio")
        return ""

    # Write WAV
    try:
        with wave.open(output_path, "wb") as wf:
            wf.setnchannels(1)
            wf.setsampwidth(2)
            wf.setframerate(rate)
            wf.writeframes(bytes(all_audio))
    except Exception as e:
        cprint(Colors.RED, f"❌ WAV write error: {e}")
        return ""

    actual_dur = len(all_audio) / 2 / rate
    if speech_detected:
        cprint(Colors.GREEN, f"✅ VAD stop ({actual_dur:.1f}s / {elapsed:.1f}s)")
    else:
        print(f"⏱️ ({actual_dur:.1f}s)")

    diag = analyze_wav(output_path)
    if diag.get("ok"):
        cprint(Colors.CYAN, f"🔎 audio: {diag['duration_s']}s, rms={diag['rms_dbfs']}dBFS")
        if diag.get("class") == "silence":
            cprint(Colors.YELLOW, "⚠️  Brak sygnału / cisza")

    return output_path


def record_audio(duration: int = 2, output_path: str = "/tmp/stts_audio.wav", device: Optional[str] = None) -> str:
    """Fixed-duration recording (legacy, use record_audio_vad for better UX)."""
    info = detect_system()
    t0 = time.perf_counter()
    cprint(Colors.GREEN, f"[{_ts()}] 🎤 Mów ({duration}s)...", end=" ")
    try:
        if info.os_name == "linux":
            cmd = ["arecord"]
            if device:
                cmd += ["-D", device]
            cmd += [
                "-d",
                str(duration),
                "-r",
                "16000",
                "-c",
                "1",
                "-f",
                "S16_LE",
                "-t",
                "wav",
                output_path,
            ]
            subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=duration + 2)
            elapsed = time.perf_counter() - t0
            print(f"✅ ({elapsed:.1f}s)")
            diag = analyze_wav(output_path)
            if diag.get("ok"):
                cprint(Colors.CYAN, f"🔎 audio: {diag['duration_s']}s, {diag['rate']}Hz, rms={diag['rms_dbfs']}dBFS, crest={diag['crest_db']}dB")
                if diag.get("class") == "silence":
                    cprint(Colors.YELLOW, "⚠️  Brak sygnału / cisza (sprawdź mikrofon, mute, wybór urządzenia)")
                elif diag.get("class") == "noise":
                    cprint(Colors.YELLOW, "⚠️  Wygląda na sam hałas (sprawdź gain/źródło wejścia)")
            return output_path
        cprint(Colors.RED, "❌ Recording supported only on Linux in this minimal build")
        return ""
    except Exception as e:
        cprint(Colors.RED, f"❌ Recording failed: {e}")
        return ""


def interactive_setup() -> dict:
    config = load_config()
    info = detect_system()

    cprint(Colors.BOLD + Colors.CYAN, "\nSTTS (python) - Setup\n")
    print(f"OS={info.os_name} RAM={info.ram_gb}GB GPU={info.gpu_name or 'none'}")

    stt_choices: List[Tuple[str, STTProvider]] = []
    for key, cls in sorted(STT_PROVIDERS.items(), key=lambda kv: kv[0]):
        try:
            ok, reason = cls.is_available(info)
        except Exception:
            ok, reason = False, "error"
        stt_choices.append((key, cls))
        state = "OK" if ok else reason
        print(f"  {len(stt_choices)}) {key} ({state})")

    stt_sel = input(f"Wybierz STT [1-{len(stt_choices)}] (ENTER=1): ").strip() or "1"
    try:
        idx = int(stt_sel)
    except ValueError:
        idx = 1
    if idx < 1 or idx > len(stt_choices):
        idx = 1

    stt_key, stt_cls = stt_choices[idx - 1]
    config["stt_provider"] = stt_key

    if stt_key == "whisper_cpp":
        available, reason = stt_cls.is_available(info)
        print(f"STT: {stt_cls.name} ({reason})")
        if not available:
            if input("Install whisper.cpp now? (y/n): ").strip().lower() == "y":
                stt_cls.install(info)

        rec = stt_cls.get_recommended_model(info)
        model = input(f"Whisper model [tiny/base/small/medium/large] (ENTER={rec}): ").strip() or rec
        config["stt_model"] = model

        if input(f"Download model {model} now? (y/n): ").strip().lower() == "y":
            stt_cls.download_model(model)
    else:
        rec = (stt_cls.get_recommended_model(info) or config.get("stt_model") or "").strip() or "nova-2"
        model = input(f"STT model (ENTER={rec}): ").strip() or rec
        config["stt_model"] = model

    tts_choices: List[Tuple[str, TTSProvider]] = []
    for key, cls in (
        ("espeak", EspeakTTS),
        ("piper", PiperTTS),
        ("spd-say", SpdSayTTS),
        ("flite", FliteTTS),
        ("say", SayTTS),
    ):
        try:
            ok, reason = cls.is_available(info)
        except Exception:
            ok, reason = False, "error"
        tts_choices.append((key, cls))
        state = "OK" if ok else reason
        print(f"  {len(tts_choices)}) {key} ({state})")

    tts_sel = input(f"Wybierz TTS [1-{len(tts_choices)}] (ENTER=1): ").strip() or "1"
    try:
        idx = int(tts_sel)
    except ValueError:
        idx = 1
    if idx < 1 or idx > len(tts_choices):
        idx = 1

    tts_key, tts_cls = tts_choices[idx - 1]
    ok, reason = tts_cls.is_available(info)

    if tts_key == "piper":
        config["tts_provider"] = "piper"
        config["tts_voice"] = input("Piper voice id (ENTER=pl_PL-gosia-medium): ").strip() or "pl_PL-gosia-medium"
        if not PiperTTS.find_piper_bin():
            if input("Pobrać i zainstalować piper binarkę (local)? (y/n): ").strip().lower() == "y":
                PiperTTS.install_local(info, config.get("piper_release_tag", "2023.11.14-2"))
        inst = PiperTTS(voice=config.get("tts_voice", ""), config=config, info=info)
        if not inst._resolve_model():
            if input("Pobrać model piper dla tego głosu? (y/n): ").strip().lower() == "y":
                PiperTTS.download_voice(config.get("tts_voice", ""), config.get("piper_voice_version", "v1.0.0"))
    else:
        if ok:
            config["tts_provider"] = tts_key
            v0 = config.get("tts_voice", "pl")
            config["tts_voice"] = input(f"TTS voice/lang (ENTER={v0}): ").strip() or v0
        else:
            config["tts_provider"] = None
            cprint(Colors.YELLOW, f"TTS '{tts_key}' niedostępny: {reason}")

    if info.os_name == "linux":
        src, sink = get_active_pulse_devices()
        if src or sink:
            cprint(Colors.CYAN, "\nAktywne urządzenia (PulseAudio):")
            if src:
                print(f"  mic: {src}")
            if sink:
                print(f"  speaker: {sink}")

        mics = list_capture_devices_linux()
        spk = list_playback_devices_linux()
        mic_choice = choose_device_interactive("Wybierz mikrofon (arecord)", mics)
        if mic_choice is None:
            det = input("Auto-wykryć mikrofon (mów teraz)? (ENTER=y / n): ").strip().lower()
            if det != "n":
                mic_choice = auto_detect_mic(mics)
        config["mic_device"] = mic_choice
        config["speaker_device"] = choose_device_interactive("Wybierz głośnik (info)", spk)
        auto_sw = input("Auto-przełączanie mikrofonu gdy cisza/hałas? (ENTER=y / n): ").strip().lower()
        config["audio_auto_switch"] = (auto_sw != "n")

    save_config(config)
    cprint(Colors.GREEN, f"✅ Saved: {_get_config_file_for_save()}")
    return config


# Dangerous command patterns (denylist)
DANGEROUS_PATTERNS = [
    r"^\s*rm\s+(-[rfRvfi\s]+)*\s*/\s*$",  # rm -rf /
    r"^\s*rm\s+(-[rfRvfi\s]+)*\s*/[a-z]+",  # rm -rf /usr, /etc, etc.
    r"^\s*dd\s+.*of=/dev/[sh]d",  # dd to disk
    r"^\s*mkfs",  # format filesystem
    r"^\s*:()\s*{\s*:\|\:&\s*}\s*;",  # fork bomb
    r"^\s*chmod\s+(-[Rrf\s]+)*\s*777\s+/",  # chmod 777 /
    r"^\s*chown\s+(-[Rrf\s]+)*\s*.*\s+/\s*$",  # chown / 
    r"^\s*shutdown",
    r"^\s*reboot",
    r"^\s*init\s+0",
    r"^\s*halt",
    r">\s*/dev/[sh]d",  # write to disk device
    r"^\s*curl.*\|\s*(ba)?sh",  # curl | sh
    r"^\s*wget.*\|\s*(ba)?sh",  # wget | sh
]

# Interactive commands that can block a voice-driven daemon.
INTERACTIVE_PATTERNS = [
    r"^\s*(?:ba)?sh\s*$",  # bash/sh
    r"^\s*zsh\s*$",
    r"^\s*fish\s*$",
    r"^\s*ssh(\s|$)",
    r"^\s*top\s*$",
    r"^\s*htop\s*$",
]

# SQL patterns (not valid shell commands)
SQL_PATTERNS = [
    r"^\s*SELECT\s+",
    r"^\s*INSERT\s+INTO\s+",
    r"^\s*UPDATE\s+\w+\s+SET\s+",
    r"^\s*DELETE\s+FROM\s+",
    r"^\s*DROP\s+(TABLE|DATABASE)\s+",
    r"^\s*CREATE\s+(TABLE|DATABASE)\s+",
    r"^\s*ALTER\s+TABLE\s+",
]


def is_dangerous_command(cmd: str) -> Tuple[bool, str]:
    """Check if command matches dangerous patterns. Returns (is_dangerous, reason)."""
    cmd_lower = cmd.lower().strip()
    for pattern in DANGEROUS_PATTERNS:
        if re.search(pattern, cmd, re.IGNORECASE):
            return True, f"Matches dangerous pattern: {pattern[:30]}..."
    return False, ""


def is_sql_command(cmd: str) -> bool:
    """Check if command looks like SQL (not shell)."""
    for pattern in SQL_PATTERNS:
        if re.search(pattern, cmd, re.IGNORECASE):
            return True
    return False


def check_command_safety(cmd: str, config: dict, dry_run: bool) -> Tuple[bool, str]:
    if dry_run:
        return False, "dry-run"

    # In daemon mode, block commands that are very likely to hang waiting for user input.
    if config.get("_daemon", False):
        for pattern in INTERACTIVE_PATTERNS:
            if re.search(pattern, cmd, re.IGNORECASE):
                if config.get("safe_mode", False):
                    if not sys.stdin.isatty():
                        cprint(Colors.YELLOW, "🔒 SAFE MODE (stdin nie jest TTY)")
                        return False, "safe-mode"
                    cprint(Colors.YELLOW, "⚠️  Interactive command in daemon mode")
                    ans = input(f"Uruchomić interaktywnie? ({cmd}) (y/n): ").strip().lower()
                    if ans == "y":
                        break
                    return False, "interactive"
                cprint(Colors.RED, "🚫 ZABLOKOWANO: interactive command in daemon mode")
                return False, "interactive"

    is_dangerous, reason = is_dangerous_command(cmd)
    if is_dangerous:
        cprint(Colors.RED, f"🚫 ZABLOKOWANO: {reason}")
        return False, "dangerous"
    if config.get("safe_mode", False):
        if not sys.stdin.isatty():
            cprint(Colors.YELLOW, "🔒 SAFE MODE (stdin nie jest TTY)")
            return False, "safe-mode"
        cprint(Colors.YELLOW, "🔒 SAFE MODE")
        ans = input("Uruchomić? (y/n): ").strip().lower()
        if ans != "y":
            return False, "safe-mode"
    return True, ""


_NLP2CMD_WORKER = None


def _resolve_nlp2cmd_python() -> str:
    v = os.environ.get("STTS_NLP2CMD_PYTHON", "").strip()
    if v:
        return v

    ve = os.environ.get("VIRTUAL_ENV", "").strip()
    if ve:
        try:
            p = Path(ve) / "bin" / "python"
            if p.exists():
                return str(p)
        except Exception:
            pass

    try:
        here = Path(__file__).resolve()
        candidates = [
            here.parent.parent / "venv" / "bin" / "python",
            here.parent.parent / "venv" / "bin" / "python3",
            here.parent / "venv" / "bin" / "python",
            here.parent / "venv" / "bin" / "python3",
        ]
        for c in candidates:
            if c.exists():
                return str(c)
    except Exception:
        pass

    return sys.executable


def nlp2cmd_prewarm(config: Optional[dict] = None) -> None:
    global _NLP2CMD_WORKER
    if not _nlp2cmd_parallel_enabled(config):
        return
    if _NLP2CMD_WORKER is not None:
        return
    try:
        _NLP2CMD_WORKER = _NLP2CMDWorker(_resolve_nlp2cmd_python())
    except Exception:
        _NLP2CMD_WORKER = None


def nlp2cmd_prewarm_force() -> None:
    global _NLP2CMD_WORKER
    if _NLP2CMD_WORKER is not None:
        return
    try:
        _NLP2CMD_WORKER = _NLP2CMDWorker(_resolve_nlp2cmd_python())
    except Exception:
        _NLP2CMD_WORKER = None


def nlp2cmd_translate(text: str, config: Optional[dict] = None, force: bool = False) -> Optional[str]:
    if not force:
        if os.environ.get("STTS_NLP2CMD_ENABLED", "0").strip() not in ("1", "true", "yes", "y"):
            return None

    text = TextNormalizer.normalize(text or "")

    def _normalize_cmd(s: str) -> str:
        out = (s or "").strip()
        if out.startswith("$"):
            out = out.lstrip("$").strip()
        out = re.sub(r"^\d+[\.)]\s*", "", out)
        out = re.sub(r"^-\s+", "", out)
        return out.strip()

    if force:
        nlp2cmd_prewarm_force()
        if _NLP2CMD_WORKER is not None:
            cmd = _NLP2CMD_WORKER.translate(text)
            if cmd:
                cmd2 = _normalize_cmd(cmd)
                return cmd2 or None
    elif _nlp2cmd_parallel_enabled(config):
        nlp2cmd_prewarm(config)
        if _NLP2CMD_WORKER is not None:
            cmd = _NLP2CMD_WORKER.translate(text)
            if cmd:
                cmd2 = _normalize_cmd(cmd)
                return cmd2 or None

    bin_name = os.environ.get("STTS_NLP2CMD_BIN", "nlp2cmd")
    args = shlex.split(os.environ.get("STTS_NLP2CMD_ARGS", "-r"))
    try:
        has_query = ("--query" in args) or ("-q" in args)
        cmd = [bin_name, *args]
        if not has_query:
            cmd += ["--query", text]
        else:
            cmd += [text]
        if not shutil.which(bin_name):
            if bin_name == "nlp2cmd":
                cmd = [_resolve_nlp2cmd_python(), "-m", "nlp2cmd.cli.main", *args]
                if not has_query:
                    cmd += ["--query", text]
                else:
                    cmd += [text]
            else:
                cprint(Colors.YELLOW, f"⚠️  {bin_name} nie znaleziony. Zainstaluj: pip install nlp2cmd")
                return None

        res = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
        out = (res.stdout or "") + (res.stderr or "")
        if res.returncode != 0 and not out.strip():
            return None
        lines = [l.strip() for l in out.splitlines() if l.strip()]
        if not lines:
            return None

        for l in lines[:10]:
            if l.startswith("Traceback"):
                return None

        for l in lines:
            if l.startswith("$"):
                candidate = l.lstrip("$").strip()
                if is_sql_command(candidate):
                    cprint(Colors.YELLOW, f"⚠️  Wykryto SQL (nie shell): {candidate[:60]}...")
                    cprint(Colors.CYAN, "💡 Użyj: ./stts sqlite3 db.sqlite \"{STT}\" lub ./stts psql -c \"{STT}\"")
                    return None
                candidate2 = _normalize_cmd(candidate)
                return candidate2 or None

        for l in lines:
            if l.startswith("```"):
                continue
            if l.startswith("📊"):
                continue
            if l.lower().startswith("time:"):
                continue
            if l.startswith("#"):
                continue
            if l.startswith("Usage:") or l.startswith("Try '") or l.startswith("Error:"):
                continue
            if l.startswith("Generating") or l.startswith("Detected") or l.startswith("✓"):
                continue
            if is_sql_command(l):
                cprint(Colors.YELLOW, f"⚠️  Wykryto SQL (nie shell): {l[:60]}...")
                cprint(Colors.CYAN, "💡 Użyj: ./stts sqlite3 db.sqlite \"{STT}\" lub ./stts psql -c \"{STT}\"")
                return None
            if re.match(r"^[a-zA-Z0-9_./-]+(\s+.+)?$", l):
                l2 = _normalize_cmd(l)
                return l2 or None

        return None
    except Exception:
        return None


def nlp2cmd_confirm(cmd: str) -> bool:
    if os.environ.get("STTS_NLP2CMD_CONFIRM", "1").strip() in ("0", "false", "no", "n"):
        return True
    print(f"\nNLP2CMD → {cmd}")
    ans = input("Uruchomić tę komendę? (y/n): ").strip().lower()
    return ans == "y"


class _NLP2CMDWorker:
    def __init__(self, python_exe: str):
        self.python_exe = python_exe
        self._lock = threading.Lock()

        code = """
import sys, json

pipeline = None
err = None
try:
    from nlp2cmd.generation.pipeline import RuleBasedPipeline
    try:
        pipeline = RuleBasedPipeline(use_enhanced_context=False)
    except TypeError:
        pipeline = RuleBasedPipeline()
except Exception as e:
    err = str(e)

for line in sys.stdin:
    s = (line or '').strip()
    if not s:
        continue
    try:
        req = json.loads(s)
    except Exception:
        req = {'text': s}
    text = str(req.get('text', '') or '')
    if not pipeline:
        out = {'ok': False, 'command': '', 'error': err or 'nlp2cmd not available'}
    else:
        try:
            r = pipeline.process(text)
            cmd = (getattr(r, 'command', '') or '').strip()
            out = {'ok': bool(cmd), 'command': cmd, 'error': ''}
        except Exception as e:
            out = {'ok': False, 'command': '', 'error': str(e)}
    sys.stdout.write(json.dumps(out, ensure_ascii=False) + '\n')
    sys.stdout.flush()
"""
        env = os.environ.copy()
        env.setdefault("NLP2CMD_USE_ENHANCED_CONTEXT", "0")
        self.proc = subprocess.Popen(
            [python_exe, "-u", "-c", code],
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            env=env,
        )
        self._stderr_thread = threading.Thread(target=self._drain_stderr, daemon=True)
        self._stderr_thread.start()

    def _drain_stderr(self):
        try:
            if not self.proc.stderr:
                return
            for _ in self.proc.stderr:
                pass
        except Exception:
            return

    def translate(self, text: str, timeout_s: float = 20.0) -> Optional[str]:
        if not text:
            return None
        if self.proc.poll() is not None:
            return None
        if not self.proc.stdin or not self.proc.stdout:
            return None

        payload = json.dumps({"text": text}, ensure_ascii=False)
        with self._lock:
            try:
                self.proc.stdin.write(payload + "\n")
                self.proc.stdin.flush()
            except Exception:
                return None

            try:
                if os.name != "nt":
                    ready, _, _ = select.select([self.proc.stdout], [], [], timeout_s)
                    if not ready:
                        return None
                line = self.proc.stdout.readline()
            except Exception:
                return None

        if not line:
            return None
        try:
            res = json.loads(line.strip())
            cmd = (res.get("command") or "").strip()
            return cmd or None
        except Exception:
            return None


def _nlp2cmd_parallel_enabled(config: Optional[dict]) -> bool:
    if config and bool(config.get("nlp2cmd_parallel")):
        return True
    v = os.environ.get("STTS_NLP2CMD_PARALLEL", "").strip().lower()
    if v in ("1", "true", "yes", "y"):
        return True
    return False


# Wake-word patterns (hejken/heyken + common STT phonetic variants).
# NOTE: Match only at the beginning of the transcript.
WAKE_WORD_PATTERNS = [
    # he(j|y)[, ]? (k)en/kan
    r"^(?:hej|hey)\W*(?:ken|kan)\b",
    r"^(?:hej|hey)\W+\w*\W*(?:ken|kan)\b",
    # joined forms
    r"^(?:hejken|heyken)\b",
    # STT sometimes spells it as letters: "a i kan" / "ai kan"
    r"^a\s*i\s*(?:ken|kan)\b",
    r"^ai\s*(?:ken|kan)\b",
    # common mishearing: "hi, kan"
    r"^hi\W*(?:ken|kan)\b",
    # Vosk often truncates to just "ken" at the start
    r"^ken\b",
    r"^kan\b",
]


def check_wake_word(text: str, patterns: Optional[List[str]] = None) -> Tuple[bool, str]:
    """Check if text starts with wake word. Returns (matched, remaining_text)."""
    if not text:
        return False, ""
    # Some STT engines may prepend punctuation/quotes or other non-word chars.
    # Make matching robust by allowing an optional non-word prefix and removing
    # the wake-word via regex substitution on the original text.
    src = str(text)
    pats = list(patterns or WAKE_WORD_PATTERNS)
    for pattern in pats:
        pat = str(pattern or "")
        if pat.startswith("^"):
            pat = pat[1:]

        full = r"^\s*[\W_]*" + pat
        if re.match(full, src, re.IGNORECASE):
            remaining = re.sub(full, "", src, count=1, flags=re.IGNORECASE)
            remaining = remaining.strip(" \t\r\n,:;.!?\"'“”‘’—-")
            return True, remaining.strip()
    return False, src


def _wake_word_phrase_to_pattern(phrase: str) -> Optional[str]:
    p = str(phrase or "").strip()
    if not p:
        return None
    # Convert literal phrase into regex that tolerates punctuation/extra spaces between words.
    parts = [re.escape(x) for x in re.split(r"\s+", p) if x]
    if not parts:
        return None
    if len(parts) == 1:
        return r"^" + parts[0] + r"\b"
    return r"^" + r"\W*".join(parts) + r"\b"


def generate_wake_word_variants(phrase: str, max_variants: int = 24) -> List[str]:
    p0 = str(phrase or "").strip()
    if not p0:
        return []

    p = re.sub(r"\s+", " ", p0)
    low = p.lower()

    out: List[str] = []
    seen = set()

    def _add(x: str):
        s = str(x or "").strip()
        if not s:
            return
        if len(out) >= int(max_variants or 0):
            return
        key = s.lower()
        if key in seen:
            return
        seen.add(key)
        out.append(s)

    _add(low)
    _add(p)
    if " " in low:
        _add(low.replace(" ", ""))

    trans_table = str.maketrans({
        "ą": "a",
        "ć": "c",
        "ę": "e",
        "ł": "l",
        "ń": "n",
        "ó": "u",
        "ś": "s",
        "ź": "z",
        "ż": "z",
    })
    _add(low.translate(trans_table))

    rules = [
        ("hej", ["hey", "ej"]),
        ("hey", ["hej", "ej"]),
        ("ken", ["kan"]),
        ("kan", ["ken"]),
        ("ch", ["h"]),
        ("rz", ["z"]),
        ("sz", ["s"]),
        ("cz", ["c"]),
    ]

    base_variants = list(out)
    for src, reps in rules:
        for v in base_variants:
            if src not in v.lower():
                continue
            for r in reps:
                _add(v.lower().replace(src, r))

    for v in list(out):
        v2 = v.lower()
        if v2.startswith("h") and len(v2) > 2:
            _add(v2[1:])
        if v2.startswith("he") and len(v2) > 3:
            _add(v2[2:])

    return out


def normalize_daemon_command(text: str) -> str:
    """Heurystyczna normalizacja komendy po wake-word (pod STT)."""
    s = (text or "").strip()
    if not s:
        return ""

    # lowercase for matching, but keep original as much as possible
    lower = s.lower().strip()
    # Common STT distortion for "lista": "js ta" / "jesta" etc.
    lower = re.sub(r"\b(j|i)s\s*ta\b", "lista", lower)
    lower = re.sub(r"\b(j|i)est\s*a\b", "lista", lower)
    lower = re.sub(r"\ba\s+lista\b", "lista", lower)
    # Remove leading filler tokens
    lower = re.sub(r"^(a|i|y)\s+", "", lower)

    # Reuse existing text normalizer (shell-oriented)
    try:
        lower = TextNormalizer.normalize(lower)
    except Exception:
        pass

    return lower.strip()


def _parse_trigger_spec(spec: str) -> Optional[Tuple[str, str, bool]]:
    """Parse trigger specification.

    Format:
      - phrase=CMD  (literal phrase match, case-insensitive)
      - /regex/=CMD (regex match)

    Returns (pattern, command, is_regex)
    """
    s = str(spec or "").strip()
    if not s or "=" not in s:
        return None
    left, right = s.split("=", 1)
    left = left.strip()
    cmd = right.strip()
    if not left or not cmd:
        return None
    if left.startswith("/") and left.endswith("/") and len(left) > 2:
        return left[1:-1], cmd, True
    return left, cmd, False


def load_triggers(trigger_specs: Optional[List[str]] = None, triggers_file: Optional[str] = None) -> List[Tuple[str, str, bool]]:
    rules: List[Tuple[str, str, bool]] = []
    for s in (trigger_specs or []):
        r = _parse_trigger_spec(s)
        if r:
            rules.append(r)

    if triggers_file:
        try:
            p = Path(triggers_file)
            if p.exists() and p.is_file():
                for line in p.read_text(encoding="utf-8").splitlines():
                    line = line.strip()
                    if not line or line.startswith("#"):
                        continue
                    r = _parse_trigger_spec(line)
                    if r:
                        rules.append(r)
        except Exception:
            pass

    return rules


def match_trigger(text: str, rules: List[Tuple[str, str, bool]]) -> Optional[str]:
    """Return command if any trigger matches."""
    t = (text or "").strip()
    if not t:
        return None
    for pat, cmd, is_regex in (rules or []):
        try:
            if is_regex:
                if re.search(pat, t, flags=re.IGNORECASE):
                    return cmd
            else:
                if t.lower() == str(pat).strip().lower():
                    return cmd
        except Exception:
            continue
    return None


def nlp2cmd_service_query(
    query: str,
    url: str = "http://localhost:8000",
    execute: bool = True,
    timeout: float = 30.0,
) -> Optional[Dict[str, Any]]:
    """Query nlp2cmd HTTP service. Returns response dict or None on error."""
    try:
        import urllib.request
        import urllib.error

        endpoint = f"{url.rstrip('/')}/query"
        payload = json.dumps({
            "query": query,
            "dsl": "shell",
            "execute": execute,
        }).encode("utf-8")

        req = urllib.request.Request(
            endpoint,
            data=payload,
            headers={"Content-Type": "application/json"},
            method="POST",
        )

        with urllib.request.urlopen(req, timeout=timeout) as resp:
            data = json.loads(resp.read().decode("utf-8"))
            return data
    except urllib.error.URLError as e:
        cprint(Colors.RED, f"❌ nlp2cmd service error: {e}")
        return None
    except Exception as e:
        cprint(Colors.RED, f"❌ nlp2cmd query error: {e}")
        return None


def nlp2cmd_service_health(url: str, timeout: float = 2.5) -> bool:
    """Check if nlp2cmd service is reachable via GET /health."""
    try:
        import urllib.request

        endpoint = f"{url.rstrip('/')}/health"
        with urllib.request.urlopen(endpoint, timeout=timeout) as resp:
            if getattr(resp, "status", 0) != 200:
                return False
            data = json.loads(resp.read().decode("utf-8"))
            return (data or {}).get("status") == "healthy"
    except Exception:
        return False


class VoiceShell:
    def __init__(self, config: dict):
        self.config = config
        self.info = detect_system(fast=bool(self.config.get("fast_start", True)))
        self._stt_unavailable_reason = None
        self._warned_stt_disabled = False
        self.stt = self._init_stt()
        self.tts = self._init_tts()
        self._suppress_wake_word_logging = False

        HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
        if HISTORY_FILE.exists():
            readline.read_history_file(str(HISTORY_FILE))
        atexit.register(lambda: readline.write_history_file(str(HISTORY_FILE)))

    def _init_stt(self) -> Optional[STTProvider]:
        provider = self.config.get("stt_provider")
        if provider in STT_PROVIDERS:
            cls = STT_PROVIDERS[provider]
            available, reason = cls.is_available(self.info)
            if available:
                self._stt_unavailable_reason = None
                return cls(
                    model=self.config.get("stt_model"),
                    language=self.config.get("language", "pl"),
                    config=self.config,
                    info=self.info,
                )

            if provider == "vosk":
                do_install = bool(self.config.get("vosk_auto_install", True))
                do_download = bool(self.config.get("vosk_auto_download", True))

                reason_s = str(reason or "")
                if do_install and ("pip install vosk" in reason_s):
                    cprint(Colors.YELLOW, "📦 Installing vosk (pip)...")
                    try:
                        cls.install(self.info)
                    except Exception:
                        pass

                available2, reason2 = cls.is_available(self.info)
                reason2_s = str(reason2 or "")

                if do_download and ("no models" in reason2_s):
                    model = (self.config.get("stt_model") or "").strip() or (cls.get_recommended_model(self.info) or "small-pl")
                    cprint(Colors.YELLOW, f"📥 Downloading Vosk model: {model}")
                    try:
                        cls.download_model(model)
                    except Exception:
                        pass
                    available2, reason2 = cls.is_available(self.info)

                if available2:
                    self._stt_unavailable_reason = None
                    return cls(
                        model=self.config.get("stt_model"),
                        language=self.config.get("language", "pl"),
                        config=self.config,
                        info=self.info,
                    )
                reason = reason2

            self._stt_unavailable_reason = str(reason)
            print(
                f"[stts] ⚠️  STT provider '{provider}' unavailable ({reason}); STT disabled",
                file=sys.stderr,
            )
        return None

    def _init_tts(self) -> Optional[TTSProvider]:
        provider = self.config.get("tts_provider")
        voice = self.config.get("tts_voice", "pl")

        if provider in TTS_PROVIDERS:
            cls = TTS_PROVIDERS[provider]
            inst = cls(voice=voice, config=self.config, info=self.info)
            available, reason = cls.is_available(self.info)
            if available:
                return inst
            if provider == "piper" and self.config.get("piper_auto_install", True):
                return inst

            # Explicit provider selected but not usable: be strict and disable TTS (avoid silent fallback).
            print(f"[stts] ⚠️  TTS provider '{provider}' unavailable ({reason}); TTS disabled", file=sys.stderr)
            return None

        # No explicit provider selected: best-effort fallback
        if shutil.which("espeak") or shutil.which("espeak-ng"):
            return EspeakTTS(voice=voice, config=self.config, info=self.info)
        return None

    def speak(self, text: str):
        if self.tts and self.config.get("auto_tts", True):
            threading.Thread(target=self.tts.speak, args=(text[:200],), daemon=True).start()

    def transcribe(self, audio_path: str) -> str:
        if os.environ.get("STTS_MOCK_STT") == "1":
            sidecar = Path(audio_path).with_suffix(Path(audio_path).suffix + ".txt")
            if sidecar.exists():
                try:
                    return sidecar.read_text(encoding="utf-8").strip()
                except Exception:
                    return ""
        if not self.stt:
            if (not self._warned_stt_disabled) and self.config.get("stt_provider"):
                self._warned_stt_disabled = True
                reason = self._stt_unavailable_reason
                if reason:
                    cprint(
                        Colors.RED,
                        f"❌ STT disabled: {self.config.get('stt_provider')} ({reason})",
                    )
                else:
                    cprint(
                        Colors.RED,
                        f"❌ STT disabled: {self.config.get('stt_provider')}",
                    )
            return ""
        t0 = time.perf_counter()
        cprint(Colors.YELLOW, f"[{_ts()}] 🔄 Rozpoznawanie...", end=" ")
        text = self.stt.transcribe(audio_path)
        elapsed = time.perf_counter() - t0
        if text:
            shown = text
            if self._suppress_wake_word_logging:
                # In daemon mode, optionally use custom wake word patterns
                pats = self.config.get("daemon_wake_patterns") if isinstance(self.config, dict) else None
                if not isinstance(pats, list):
                    pats = None
                matched, remaining = check_wake_word(text, patterns=pats)
                if matched and remaining:
                    shown = remaining
            cprint(Colors.GREEN, f"✅ \"{shown}\" ({elapsed:.1f}s)")
        else:
            cprint(Colors.RED, f"❌ Nie rozpoznano ({elapsed:.1f}s)")
        return text

    def listen(self, stt_file: Optional[str] = None) -> str:
        mic = self.config.get("mic_device")
        if stt_file:
            audio_path = stt_file
        elif self.config.get("vad_enabled", True) and self.info.os_name == "linux":
            audio_path = record_audio_vad(
                max_duration=float(self.config.get("timeout", 5)),
                device=mic,
                silence_ms=self.config.get("vad_silence_ms", 800),
                threshold_db=self.config.get("vad_threshold_db", -45.0),
            )
        else:
            audio_path = record_audio(self.config.get("timeout", 2), device=mic)
        if not audio_path:
            return ""
        diag = analyze_wav(audio_path)
        if diag.get("ok") and diag.get("class") in ("silence", "noise") and stt_file is None:
            if self.config.get("audio_auto_switch") and self.info.os_name == "linux":
                cprint(Colors.YELLOW, "🔁 Próba auto-wyboru mikrofonu...")
                candidates = list_capture_devices_linux()
                best = None
                best_score = -1e9
                for dev, _ in candidates[:6]:
                    if mic and dev == mic:
                        continue
                    tmp = "/tmp/stts_probe.wav"
                    p = record_audio(2, output_path=tmp, device=dev)
                    if not p:
                        continue
                    d = analyze_wav(p)
                    if not d.get("ok"):
                        continue
                    score = float(d.get("rms_dbfs", -120)) + float(d.get("crest_db", 0))
                    if d.get("class") != "silence" and score > best_score:
                        best = dev
                        best_score = score
                if best:
                    cprint(Colors.GREEN, f"✅ Wybrano mikrofon: {best}")
                    self.config["mic_device"] = best
                    save_config(self.config)
                    audio_path = record_audio(self.config.get("timeout", 5), device=best)
                    if not audio_path:
                        return ""
        return self.transcribe(audio_path)

    def run_command(self, cmd: str):
        try:
            result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=60)
            return result.stdout + result.stderr, result.returncode
        except subprocess.TimeoutExpired:
            return "⏰ Timeout (60s)", 124
        except Exception as e:
            return f"❌ Error: {e}", 1

    def run_command_streaming(self, cmd: str):
        """Strumieniowe wykonanie komendy z wypisywaniem linia po linii."""
        try:
            if isinstance(cmd, list):
                argv = cmd
            else:
                if os.name != "nt":
                    argv = ["/bin/bash", "-c", cmd]
                else:
                    argv = ["cmd", "/c", cmd]

            process = subprocess.Popen(
                argv,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                bufsize=1,
                universal_newlines=True,
            )
            output_parts = []
            for line in iter(process.stdout.readline, ''):
                if line:
                    print(line, end='', flush=True)
                    output_parts.append(line)
            process.stdout.close()
            return_code = process.wait()
            return ''.join(output_parts), return_code
        except Exception as e:
            return f"❌ Error: {e}", 1

    def run_command_any(self, cmd: str):
        # If pexpect exists, support interactive prompts with TTS + voice reply.
        def _has_pexpect():
            try:
                import pexpect
                return True
            except ImportError:
                return False

        if sys.stdin.isatty() and _has_pexpect():
            try:
                return self.run_command_interactive(cmd)
            except Exception:
                pass
        # W pipe (nie-TTY) użyj strumieniowania
        if not sys.stdin.isatty():
            out, code = self.run_command_streaming(cmd)
            return out, code, True
        if self.config.get("stream_cmd", False):
            out, code = self.run_command_streaming(cmd)
            return out, code, True
        out, code = self.run_command(cmd)
        return out, code, False

    def run_command_interactive(self, cmd: str):
        import pexpect

        if os.name != "nt":
            child = pexpect.spawn("/bin/bash", ["-c", cmd], encoding="utf-8", timeout=1)
        else:
            child = pexpect.spawn(cmd, encoding="utf-8", timeout=1)
        output_parts: List[str] = []
        last_nonempty = ""
        printed = False

        def _flush(text: str):
            nonlocal last_nonempty, printed
            if text:
                printed = True
                print(text, end="", flush=True)
                output_parts.append(text)
                for line in text.splitlines():
                    s = line.strip()
                    if s:
                        last_nonempty = s

        while True:
            try:
                idx = child.expect(["\n", pexpect.EOF, pexpect.TIMEOUT])
                if idx == 0:
                    _flush(child.before + "\n")
                elif idx == 1:
                    _flush(child.before)
                    break
                else:
                    # No output for a moment -> likely waiting for input
                    pending = (child.before or "").strip()
                    prompt_text = pending or last_nonempty
                    if prompt_text:
                        cprint(Colors.MAGENTA, f"📢 {prompt_text[:120]}")
                        self.speak(prompt_text)

                    reply = ""
                    if self.config.get("prompt_voice_first", True):
                        reply = self.listen()
                        reply = (reply or "").strip().lower()
                        if any(x in (prompt_text or "").lower() for x in ("y/n", "[y/n]", "(y/n)", "yes/no")):
                            if reply in ("tak", "t", "yes", "y", "ok"):
                                reply = "y"
                            elif reply in ("nie", "n", "no"):
                                reply = "n"
                    if not reply:
                        reply = input("⌨️  Odpowiedź: ")
                    child.sendline(reply)
            except pexpect.exceptions.EOF:
                break

        try:
            child.close()
        except Exception:
            pass

        code = child.exitstatus if child.exitstatus is not None else (child.status or 0)
        return "".join(output_parts), code, printed

    def run(self):
        PS1 = f"{Colors.GREEN}🔊 stts(py)>{Colors.NC} "
        cprint(Colors.BOLD + Colors.CYAN, "\nSTTS (python) - Voice Shell\n")
        if self.info.os_name == "linux":
            src, sink = get_active_pulse_devices()
            if src or sink:
                cprint(Colors.CYAN, "Aktywne urządzenia (PulseAudio):")
                if src:
                    print(f"  mic: {src}")
                if sink:
                    print(f"  speaker: {sink}")

        stt_state = "disabled"
        if self.stt:
            stt_state = f"{getattr(self.stt, 'name', 'stt')} model={getattr(self.stt, 'model', '')}"
        else:
            reason = f" reason={self._stt_unavailable_reason}" if self._stt_unavailable_reason else ""
            stt_state = f"disabled (stt_provider={self.config.get('stt_provider')} stt_model={self.config.get('stt_model')}{reason})"
        print(f"STT: {stt_state}")

        tts_state = "disabled"
        if self.tts:
            tts_state = f"{getattr(self.tts, 'name', 'tts')} voice={getattr(self.tts, 'voice', '')}"
        print(f"TTS: {tts_state}")
        print("Komendy: ENTER=STT, 'exit'=wyjście, 'setup'=konfiguracja, 'audio'=urządzenia, 'meter'=poziomy")
        if self.config.get("startup_tts", True):
            self.speak("Powiedz co chcesz zrobić. Naciśnij enter i mów do mikrofonu.")

        while True:
            try:
                cmd = input(PS1).strip()
                if cmd in ["exit", "quit", "q"]:
                    break
                if cmd == "setup":
                    self.config = interactive_setup()
                    self.stt = self._init_stt()
                    self.tts = self._init_tts()
                    continue
                if cmd == "audio" and self.info.os_name == "linux":
                    self.config["mic_device"] = choose_device_interactive("Wybierz mikrofon (arecord)", list_capture_devices_linux())
                    self.config["speaker_device"] = choose_device_interactive("Wybierz głośnik (info)", list_playback_devices_linux())
                    save_config(self.config)
                    continue
                if cmd == "meter" and self.info.os_name == "linux":
                    mics = list_capture_devices_linux()
                    res = mic_meter(mics)
                    self.config["mic_device"] = res.get("selected")
                    save_config(self.config)
                    continue
                if cmd.startswith("nlp "):
                    nl = cmd[4:].strip()
                    if not nl:
                        continue
                    translated = nlp2cmd_translate(nl)
                    if translated and nlp2cmd_confirm(translated):
                        cmd = translated
                    else:
                        continue
                if not cmd:
                    if self.config.get("mic_device") is None and self.info.os_name == "linux" and self.config.get("audio_auto_switch"):
                        det = auto_detect_mic(list_capture_devices_linux())
                        if det:
                            self.config["mic_device"] = det
                            save_config(self.config)
                    nlp2cmd_prewarm(self.config)
                    cmd = self.listen()
                    if not cmd:
                        continue
                    translated = nlp2cmd_translate(cmd, config=self.config)
                    if translated and nlp2cmd_confirm(translated):
                        cmd = translated

                cprint(Colors.BLUE, f"▶️  {cmd}")

                # Safety check in interactive mode
                is_dangerous, reason = is_dangerous_command(cmd)
                if is_dangerous:
                    cprint(Colors.RED, f"🚫 ZABLOKOWANO: {reason}")
                    continue
                if self.config.get("safe_mode", False):
                    cprint(Colors.YELLOW, f"🔒 SAFE MODE")
                    ans = input("Uruchomić? (y/n): ").strip().lower()
                    if ans != "y":
                        continue

                output, code, printed = self.run_command_any(cmd)
                if output.strip() and not printed:
                    print(output)

                lines = [l.strip() for l in output.splitlines() if l.strip()]
                if lines:
                    last = lines[-1]
                    if last != cmd and len(last) > 3:
                        cprint(Colors.MAGENTA, f"📢 {last[:80]}")
                        self.speak(last)

                if code != 0:
                    cprint(Colors.RED, f"❌ Exit code: {code}")
            except KeyboardInterrupt:
                print()
                continue
            except EOFError:
                break

    def run_daemon(
        self,
        nlp2cmd_url: str = "http://localhost:8000",
        execute: bool = True,
        nlp2cmd_timeout: float = 30.0,
        log_file: Optional[str] = None,
        triggers: Optional[List[Tuple[str, str, bool]]] = None,
        wake_word: Optional[str] = None,
    ) -> int:
        """Run in daemon mode: continuous wake-word listening + nlp2cmd service."""
        import datetime

        def log(msg: str):
            ts = datetime.datetime.now().strftime("%H:%M:%S")
            line = f"[{ts}] {msg}"
            print(line, file=sys.stderr, flush=True)
            if log_file:
                try:
                    with open(log_file, "a") as f:
                        f.write(line + "\n")
                except Exception:
                    pass

        ww = (wake_word or "hejken").strip() or "hejken"
        try:
            self.config["_daemon"] = True
        except Exception:
            pass
        cprint(Colors.BOLD + Colors.CYAN, f"\n🎙️  STTS Daemon Mode (wake-word: {ww})\n")
        log(f"nlp2cmd service: {nlp2cmd_url}")
        log(f"nlp2cmd timeout: {nlp2cmd_timeout}s")
        log(f"execute commands: {execute}")
        log(f"Say '{ww} <command>' to execute. Ctrl+C to stop.")

        rules = list(triggers or [])
        if rules:
            log(f"triggers loaded: {len(rules)}")

        wake_patterns: Optional[List[str]] = None
        if wake_word:
            pat = _wake_word_phrase_to_pattern(ww)
            if pat:
                wake_patterns = [pat]
                log(f"wake-word: {ww}")

        # For very short wake-words (e.g. "hej") Vosk often misses the token.
        # Use a 2-stage mode: first recognize only the wake-word via Vosk grammar,
        # then record a second utterance for the actual command (no grammar).
        wake_only_two_stage = False
        try:
            if (self.config.get("stt_provider") == "vosk") and wake_word and len(ww) <= 3:
                wake_only_two_stage = True
                log("wake-word mode: two-stage (vosk grammar)")
        except Exception:
            wake_only_two_stage = False

        prev_grammar = None
        if wake_only_two_stage:
            try:
                prev_grammar = self.config.get("stt_vosk_grammar")
            except Exception:
                prev_grammar = None

        log("🔎 Checking nlp2cmd /health ...")
        if not nlp2cmd_service_health(nlp2cmd_url, timeout=2.5):
            log("❌ nlp2cmd service is not healthy / not reachable")
            log("   Start it first, e.g.: nlp2cmd service --host 0.0.0.0 --port 8008")
            if self.tts:
                try:
                    self.speak("Serwis nlp2cmd nie odpowiada")
                except Exception:
                    pass
            return 2

        if self.tts and self.config.get("startup_tts", True):
            self.speak(f"Słucham. Powiedz {ww} i wydaj polecenie.")

        self._suppress_wake_word_logging = True

        while True:
            try:
                # Continuous listening
                log("🎤 Listening...")
                if wake_only_two_stage:
                    try:
                        # Restrict decoding to wake-word only
                        grammar_words = generate_wake_word_variants(ww, max_variants=24)
                        if not grammar_words:
                            grammar_words = [ww]
                        log(f"wake-word grammar variants: {len(grammar_words)}")
                        self.config["stt_vosk_grammar"] = json.dumps(grammar_words, ensure_ascii=False)
                    except Exception:
                        pass
                    text = self.listen()
                    # Restore grammar ASAP (command should not be restricted)
                    try:
                        if prev_grammar is None:
                            self.config.pop("stt_vosk_grammar", None)
                        else:
                            self.config["stt_vosk_grammar"] = prev_grammar
                    except Exception:
                        pass
                else:
                    text = self.listen()

                if not text:
                    if wake_only_two_stage:
                        log("⏭️  Wake-word stage: no transcript (try speaking louder / closer)")
                    continue

                log(f"📝 Heard: {text}")

                # Check for wake word
                matched, remaining = check_wake_word(text, patterns=wake_patterns)

                if not matched:
                    log("⏭️  No wake word, ignoring")
                    continue

                if not remaining:
                    # Wake word only (or two-stage wake-only mode), listen for command
                    log("🔔 Wake word detected, listening for command...")
                    if self.tts:
                        self.speak("Słucham")

                    # In two-stage mode, always capture command in a separate utterance
                    remaining = self.listen()
                    if not remaining:
                        log("❌ No command heard")
                        continue
                    remaining = normalize_daemon_command(remaining)
                    log(f"📝 Command: {remaining}")
                else:
                    # Wake word + command in one utterance -> log only the command
                    remaining = normalize_daemon_command(remaining)
                    log(f"📝 Command: {remaining}")

                if not remaining:
                    log("❌ Empty command after normalization")
                    continue

                # Triggers: if matched, execute local command without calling nlp2cmd
                trig_cmd = match_trigger(remaining, rules)
                if trig_cmd:
                    log(f"⚡ Trigger matched -> {trig_cmd}")
                    ok, reason = check_command_safety(trig_cmd, self.config, dry_run=False)
                    if not ok:
                        log(f"🚫 Trigger blocked: {reason}")
                        continue
                    out, code, printed = self.run_command_any(trig_cmd)
                    if out.strip() and not printed:
                        print(out)
                    if code != 0:
                        log(f"❌ Exit code: {code}")
                    continue

                # Hint domain for nlp2cmd service (service may ignore 'dsl' field and rely on text detection)
                query_text = f"shell: {remaining}"

                # Query nlp2cmd service
                log(f"🚀 Sending to nlp2cmd: {query_text}")
                result = nlp2cmd_service_query(
                    query=query_text,
                    url=nlp2cmd_url,
                    execute=execute,
                    timeout=float(nlp2cmd_timeout or 30.0),
                )
                if not result:
                    log("❌ nlp2cmd query failed")
                    if self.tts:
                        self.speak("Nie udało się przetworzyć")
                    continue

                if not result.get("success"):
                    errors = result.get("errors") or ["Unknown error"]
                    log(f"❌ nlp2cmd error: {errors}")
                    if self.tts:
                        self.speak(f"Błąd: {errors[0][:50]}")
                    continue

                cmd = result.get("command", "")
                confidence = result.get("confidence", 0)
                log(f"✅ Command: {cmd} (confidence: {confidence:.2f})")

                if self.tts:
                    self.speak(f"Wykonuję: {cmd[:80]}")

                # Check if nlp2cmd already executed
                exec_result = result.get("execution_result")
                if exec_result:
                    if exec_result.get("success"):
                        exit_code = exec_result.get("exit_code")
                        duration_ms = exec_result.get("duration_ms")
                        log(f"🏁 Executed by nlp2cmd service (exit_code={exit_code}, duration_ms={duration_ms})")

                        stdout = exec_result.get("stdout", "") or ""
                        stderr = exec_result.get("stderr", "") or ""

                        if stdout:
                            try:
                                print(stdout, end="" if stdout.endswith("\n") else "\n", flush=True)
                            except Exception:
                                pass
                        if stderr:
                            try:
                                print(stderr, end="" if stderr.endswith("\n") else "\n", file=sys.stderr, flush=True)
                            except Exception:
                                pass

                        if stdout.strip() and self.tts:
                            lines = [l.strip() for l in stdout.splitlines() if l.strip()]
                            if lines:
                                self.speak(lines[-1][:100])
                    else:
                        exit_code = exec_result.get("exit_code")
                        duration_ms = exec_result.get("duration_ms")
                        stderr = exec_result.get("stderr", "") or ""
                        stdout = exec_result.get("stdout", "") or ""

                        log(f"❌ Execution failed in nlp2cmd service (exit_code={exit_code}, duration_ms={duration_ms})")
                        if stdout:
                            try:
                                print(stdout, end="" if stdout.endswith("\n") else "\n", flush=True)
                            except Exception:
                                pass
                        if stderr:
                            try:
                                print(stderr, end="" if stderr.endswith("\n") else "\n", file=sys.stderr, flush=True)
                            except Exception:
                                pass
                        if self.tts:
                            self.speak("Komenda nie powiodła się")
                else:
                    # Execute locally if not executed by service
                    log(f"▶️  Executing locally (nlp2cmd returned only translation): {cmd}")

                    ok, reason = check_command_safety(cmd, self.config, dry_run=False)
                    if not ok:
                        log(f"🚫 Blocked (local execute): {reason}")
                        if self.tts:
                            self.speak("Zablokowano komendę")
                        continue

                    out, code, _ = self.run_command_any(cmd)
                    if out.strip():
                        print(out, flush=True)
                        lines = [l.strip() for l in out.splitlines() if l.strip()]
                        if lines and self.tts:
                            self.speak(lines[-1][:100])
                    if code != 0:
                        log(f"❌ Exit code: {code}")

            except KeyboardInterrupt:
                log("🛑 Stopping daemon...")
                break
            except Exception as e:
                log(f"❌ Error: {e}")
                continue

        log("👋 Daemon stopped")
        return 0


def parse_args(argv: List[str]):
    stt_file = None
    stt_only = False
    stt_once = False
    stt_stream_shell = False
    stream_shell_cmd = None
    setup = False
    init = None
    tts_provider = None
    tts_voice = None
    tts_stdin = False
    tts_test = False
    tts_test_text = None
    install_piper = False
    download_piper_voice = None
    help_ = False
    dry_run = False
    safe_mode = False
    stream_cmd = None
    fast_start = None
    stt_gpu_layers = None
    stt_provider = None
    stt_model = None
    timeout_s = None
    vad_silence_ms = None
    list_stt = False
    list_tts = False
    nlp2cmd_parallel = None
    daemon_mode = False
    nlp2cmd_url = None
    nlp2cmd_timeout_s = None
    daemon_log = None
    daemon_no_execute = False
    daemon_triggers: List[str] = []
    daemon_triggers_file = None
    daemon_wake_word = None
    rest: List[str] = []

    it = iter(argv)
    for a in it:
        if a == "--stt-provider":
            stt_provider = next(it, None)
        elif a == "--stt-model":
            stt_model = next(it, None)
        elif a == "--timeout":
            try:
                timeout_s = float((next(it, None) or "").strip())
            except Exception:
                timeout_s = None
        elif a == "--vad-silence-ms":
            try:
                vad_silence_ms = int((next(it, None) or "").strip())
            except Exception:
                vad_silence_ms = None
        elif a == "--stt-file":
            stt_file = next(it, None)
        elif a == "--stt-only":
            stt_only = True
        elif a == "--stt-once":
            stt_once = True
        elif a == "--stt-stream-shell":
            stt_stream_shell = True
        elif a == "--cmd":
            stream_shell_cmd = next(it, None)
        elif a == "--setup":
            setup = True
        elif a == "--init":
            init = next(it, None)
        elif a == "--tts-provider":
            tts_provider = next(it, None)
        elif a == "--tts-voice":
            tts_voice = next(it, None)
        elif a == "--tts-stdin":
            tts_stdin = True
        elif a == "--tts-test":
            tts_test = True
            tts_test_text = next(it, None)
        elif a == "--install-piper":
            install_piper = True
        elif a == "--download-piper-voice":
            download_piper_voice = next(it, None)
        elif a == "--dry-run":
            dry_run = True
        elif a == "--safe-mode":
            safe_mode = True
        elif a == "--stream":
            stream_cmd = True
        elif a == "--no-stream":
            stream_cmd = False
        elif a == "--fast-start":
            fast_start = True
        elif a == "--full-start":
            fast_start = False
        elif a == "--stt-gpu-layers":
            try:
                stt_gpu_layers = int((next(it, None) or "0").strip())
            except Exception:
                stt_gpu_layers = 0
        elif a == "--nlp2cmd-parallel":
            nlp2cmd_parallel = True
        elif a == "--no-nlp2cmd-parallel":
            nlp2cmd_parallel = False
        elif a == "--list-stt":
            list_stt = True
        elif a == "--list-tts":
            list_tts = True
        elif a in ("--daemon", "--service"):
            daemon_mode = True
        elif a == "--nlp2cmd-url":
            nlp2cmd_url = next(it, None)
        elif a == "--nlp2cmd-timeout":
            try:
                nlp2cmd_timeout_s = float((next(it, None) or "").strip())
            except Exception:
                nlp2cmd_timeout_s = None
        elif a == "--daemon-log":
            daemon_log = next(it, None)
        elif a == "--no-execute":
            daemon_no_execute = True
        elif a == "--trigger":
            v = next(it, None)
            if v:
                daemon_triggers.append(v)
        elif a == "--triggers-file":
            daemon_triggers_file = next(it, None)
        elif a == "--wake-word":
            daemon_wake_word = next(it, None)
        elif a in ("--help", "-h"):
            help_ = True
        else:
            rest.append(a)
    return stt_file, stt_only, stt_once, stt_stream_shell, stream_shell_cmd, setup, init, tts_provider, tts_voice, tts_stdin, tts_test, tts_test_text, install_piper, download_piper_voice, help_, dry_run, safe_mode, stream_cmd, fast_start, stt_gpu_layers, stt_provider, stt_model, timeout_s, vad_silence_ms, list_stt, list_tts, nlp2cmd_parallel, daemon_mode, nlp2cmd_url, nlp2cmd_timeout_s, daemon_log, daemon_no_execute, daemon_triggers, daemon_triggers_file, daemon_wake_word, rest


def tts_test(shell: "VoiceShell", text: str) -> int:
    msg = (text or "Test syntezatora mowy")[:200]
    if not shell.tts:
        print(f"[stts] TTS disabled (tts_provider={shell.config.get('tts_provider')} tts_voice={shell.config.get('tts_voice')})", file=sys.stderr)
        return 2
    try:
        shell.tts.speak(msg)
        return 0
    except Exception as e:
        print(f"[stts] TTS error: {e}", file=sys.stderr)
        return 3


def tts_from_stdin(shell: "VoiceShell") -> int:
    data = ""
    try:
        data = sys.stdin.read()
    except Exception:
        data = ""

    if data:
        try:
            print(data, end="")
        except BrokenPipeError:
            return 0

    lines = [l.strip() for l in (data or "").splitlines() if l.strip()]
    last = lines[-1] if lines else ""
    if not last:
        return 1

    try:
        shell.speak(last)
    except Exception:
        pass
    return 0


def apply_quick_tts(config: dict, tts_provider: Optional[str], tts_voice: Optional[str]) -> dict:
    cfg = dict(config or {})
    if tts_provider is not None:
        v = str(tts_provider).strip()
        if v == "espeak-ng":
            v = "espeak"
        cfg["tts_provider"] = v or None
    if tts_voice is not None:
        cfg["tts_voice"] = str(tts_voice).strip() or cfg.get("tts_voice")
    return cfg


def quick_init(init: str) -> dict:
    cfg = load_config()
    s = str(init or "").strip()
    if not s:
        return cfg

    provider = s
    model = None
    if ":" in s:
        provider, model = s.split(":", 1)
        provider = provider.strip()
        model = (model or "").strip() or None

    if provider in ("whisper", "whisper.cpp"):
        provider = "whisper_cpp"

    cfg["stt_provider"] = provider or None
    if model is not None:
        cfg["stt_model"] = model

    save_config(cfg)

    try:
        if provider == "whisper_cpp" and model:
            WhisperCppSTT.download_model(model)
    except Exception:
        pass

    return cfg


def argv_to_cmd(args: List[str]) -> str:
    if os.name == "nt":
        return subprocess.list2cmdline([str(a) for a in (args or [])])
    try:
        return shlex.join([str(a) for a in (args or [])])
    except Exception:
        return " ".join(shlex.quote(str(a)) for a in (args or []))


def expand_placeholders(args: List[str], shell: 'VoiceShell', config: dict, stt_file: Optional[str] = None) -> Optional[List[str]]:
    """Expand placeholders like {STT} in command arguments."""
    expanded = []
    stt_used = False

    needs_stt = any(("{STT}" in a) or ("{STT_STREAM}" in a) for a in args)
    text = None
    if needs_stt:
        stt_used = True
        # Listen for speech input
        with contextlib.redirect_stdout(sys.stderr):
            text = shell.listen(stt_file=stt_file) if stt_file else shell.listen()
        if not text:
            cprint(Colors.RED, "❌ No speech input captured")
            return None

    for arg in args:
        if text is not None:
            expanded_arg = arg.replace("{STT}", text).replace("{STT_STREAM}", text)
            expanded.append(expanded_arg)
        else:
            expanded.append(arg)

    if stt_used and config.get("auto_tts", True) and shell.tts:
        # If STT was used, speak the final command
        cmd = argv_to_cmd(expanded)
        shell.tts.speak(cmd[:200])
    
    return expanded


def main():
    config = load_config()
    stt_file, stt_only, stt_once, stt_stream_shell, stream_shell_cmd, setup, init, tts_provider, tts_voice, tts_stdin, tts_test_flag, tts_test_text, install_piper, download_piper_voice, help_, dry_run, safe_mode, stream_cmd, fast_start, stt_gpu_layers, stt_provider_arg, stt_model_arg, timeout_s, vad_silence_ms, list_stt, list_tts, nlp2cmd_parallel, daemon_mode, nlp2cmd_url, nlp2cmd_timeout_s, daemon_log, daemon_no_execute, daemon_triggers, daemon_triggers_file, daemon_wake_word, rest = parse_args(sys.argv[1:])

    # Apply CLI overrides for STT provider/model
    if stt_provider_arg:
        config["stt_provider"] = stt_provider_arg
    if stt_model_arg:
        config["stt_model"] = stt_model_arg

    if (not stt_once) and (not stt_file) and (not rest) and (not tts_stdin) and (not tts_test_flag) and (not setup) and (not init):
        if sys.stdin.isatty() and (not sys.stdout.isatty()):
            stt_once = True

    # CLI --safe-mode overrides config
    if safe_mode:
        config["safe_mode"] = True

    if stream_cmd is not None:
        config["stream_cmd"] = bool(stream_cmd)
    if fast_start is not None:
        config["fast_start"] = bool(fast_start)
    if stt_gpu_layers is not None:
        config["stt_gpu_layers"] = int(stt_gpu_layers)
    if nlp2cmd_parallel is not None:
        config["nlp2cmd_parallel"] = bool(nlp2cmd_parallel)

    if timeout_s is not None:
        try:
            config["timeout"] = float(timeout_s)
        except Exception:
            pass
    if vad_silence_ms is not None:
        try:
            config["vad_silence_ms"] = int(vad_silence_ms)
        except Exception:
            pass

    if help_:
        print(__doc__)
        print("\nOpcje bezpieczeństwa:")
        print("  --dry-run      Pokaż komendę bez wykonania")
        print("  --safe-mode    Zawsze pytaj przed wykonaniem")
        print("\nSTT (override z CLI):")
        print("  --stt-provider NAME   Nadpisz STT provider (np. whisper_cpp/vosk/deepgram)")
        print("  --stt-model VALUE     Nadpisz model STT (np. small-pl dla vosk)")
        print("\nTryb voice-shell (placeholder):")
        print("  --stt-stream-shell   Pętla: STT(VAD) → podstaw {STT}/{STT_STREAM} → uruchom komendę")
        print("  --cmd CMD            Szablon komendy (np. 'nlp2cmd -r --query \"{STT}\" --auto-confirm')")
        print("\nSzybkość / interaktywność:")
        print("  --stream / --no-stream     Strumieniuj output komendy (bez buforowania)")
        print("  --fast-start / --full-start Szybszy start (mniej detekcji sprzętu)")
        print("\nNagrywanie (mic/VAD):")
        print("  --timeout SECONDS      Maksymalny czas nagrania (domyślnie 5s)")
        print("  --vad-silence-ms MS     Czas ciszy do potwierdzenia końca wypowiedzi (ms)")
        print("\nGPU (STT / whisper.cpp):")
        print("  --stt-gpu-layers N         Liczba warstw na GPU (-ngl), wymaga build GPU")
        print("  --list-stt                 Lista dostępnych providerów STT")
        print("  --list-tts                 Lista dostępnych providerów TTS")
        print("\nTryby pipeline:")
        print("  --tts-stdin    Czytaj stdin i przeczytaj na głos ostatnią niepustą linię")
        print("  --tts-test [TEXT]  Zrób test TTS i zakończ")
        print("\nAutomatyczne TTS (piper):")
        print("  --install-piper        Pobierz piper binarkę do ~/.config/stts-python/bin")
        print("  --download-piper-voice VOICE  Pobierz piper voice do ~/.config/stts-python/models/piper/")
        print("\nZmienne środowiskowe:")
        print("  STTS_SAFE_MODE=1       Włącz tryb bezpieczny")
        print("  STTS_VAD_ENABLED=1     Włącz VAD auto-stop (domyślnie)")
        print("  STTS_VAD_SILENCE_MS=800  Czas ciszy do zatrzymania (ms)")
        print("  STTS_STREAM=1          Domyślnie strumieniuj output komend")
        print("  STTS_FAST_START=1      Domyślnie szybki start (mniej detekcji)")
        print("  STTS_STT_GPU_LAYERS=35  whisper.cpp: liczba warstw na GPU (-ngl)")
        print("  STTS_STT_PROMPT=...    whisper.cpp: prompt (jeśli binarka wspiera --prompt/-p)")
        print("  STTS_DEEPGRAM_KEY=...  Deepgram API key (dla STT provider=deepgram)")
        print("  STTS_DEEPGRAM_MODEL=... Deepgram model (np. nova-2)")
        print("  STTS_PIPER_AUTO_INSTALL=1  Auto-install piper binarki (local)")
        print("  STTS_PIPER_AUTO_DOWNLOAD=1 Auto-download modelu piper dla tts_voice")
        print("\nTryb daemon (wake-word + nlp2cmd service):")
        print("  --daemon / --service   Uruchom w trybie ciągłego nasłuchiwania (wake-word: hejken)")
        print("  --nlp2cmd-url URL      URL serwisu nlp2cmd (domyślnie: http://localhost:8000)")
        print("  --nlp2cmd-timeout SEC  Timeout na HTTP /query do nlp2cmd (domyślnie: 30s)")
        print("  --daemon-log FILE      Zapisz logi do pliku")
        print("  --no-execute           Tylko tłumacz (nie wykonuj komend)")
        print("  --trigger SPEC         Trigger: fraza=CMD lub /regex/=CMD (omija nlp2cmd)")
        print("  --triggers-file FILE   Plik z triggerami (linia: fraza=CMD lub /regex/=CMD)")
        print("  --wake-word PHRASE     Ustaw jedną frazę wake-word (np. 'hejken'), bez wariantów")
        print("\nPrzykład uruchomienia:")
        print("  # Terminal 1: nlp2cmd service")
        print("  nlp2cmd service --host 0.0.0.0 --port 8000")
        print("  # Terminal 2: stts daemon")
        print("  ./stts --daemon --nlp2cmd-url http://localhost:8000")
        return 0

    if list_stt:
        info = detect_system(fast=True)
        for k, cls in sorted(STT_PROVIDERS.items(), key=lambda kv: kv[0]):
            try:
                ok, reason = cls.is_available(info)
            except Exception:
                ok, reason = False, "error"
            print(f"{k}: {'ok' if ok else 'no'} ({reason})")
        return 0

    if list_tts:
        info = detect_system(fast=True)
        for k, cls in sorted(TTS_PROVIDERS.items(), key=lambda kv: kv[0]):
            try:
                ok, reason = cls.is_available(info)
            except Exception:
                ok, reason = False, "error"
            print(f"{k}: {'ok' if ok else 'no'} ({reason})")
        return 0

    if tts_provider is not None or tts_voice is not None:
        config = apply_quick_tts(config, tts_provider, tts_voice)
        save_config(config)
        cprint(Colors.GREEN, f"✅ Saved TTS: provider={config.get('tts_provider')} voice={config.get('tts_voice')}")
        # If user only wanted to configure TTS, exit.
        if not (init or setup or stt_once or stt_file or rest or tts_stdin):
            return 0

    if install_piper:
        PiperTTS.install_local(detect_system(), config.get("piper_release_tag", "2023.11.14-2"))
        if not (init or setup or stt_once or stt_file or rest or tts_stdin or tts_test_flag or download_piper_voice):
            return 0

    if download_piper_voice:
        PiperTTS.download_voice(download_piper_voice, config.get("piper_voice_version", "v1.0.0"))
        if not (init or setup or stt_once or stt_file or rest or tts_stdin or tts_test_flag):
            return 0

    if init:
        config = quick_init(init)
    elif setup or (config.get("stt_provider") is None and config.get("tts_provider") is None and not rest and not stt_file and not tts_stdin):
        config = interactive_setup()

    shell = VoiceShell(config)

    if tts_test_flag:
        return tts_test(shell, tts_test_text or "")

    if tts_stdin:
        return tts_from_stdin(shell)

    # Daemon mode: wake-word + nlp2cmd service
    if daemon_mode:
        url = nlp2cmd_url or os.environ.get("STTS_NLP2CMD_URL", "http://localhost:8000")
        execute = not daemon_no_execute
        rules = load_triggers(daemon_triggers, daemon_triggers_file)
        wake_word = (daemon_wake_word or os.environ.get("STTS_WAKE_WORD", "")).strip() or None
        if wake_word:
            pat = _wake_word_phrase_to_pattern(wake_word)
            if pat:
                config["daemon_wake_patterns"] = [pat]
        nlp2cmd_timeout = float(nlp2cmd_timeout_s or 30.0)
        code = shell.run_daemon(
            nlp2cmd_url=url,
            execute=execute,
            nlp2cmd_timeout=nlp2cmd_timeout,
            log_file=daemon_log,
            triggers=rules,
            wake_word=wake_word,
        )
        return int(code or 0)

    if stt_stream_shell:
        if not stream_shell_cmd:
            cprint(Colors.RED, "❌ Brak --cmd w trybie --stt-stream-shell")
            return 2

        PS1 = f"{Colors.GREEN}🔴 captions>{Colors.NC} "
        print("Voice shell (placeholder): mów do mikrofonu, STT wstawiane w {STT}/{STT_STREAM}. CTRL+C = pomiń, CTRL+D = wyjście", file=sys.stderr)

        one_shot = bool(stt_file)
        while True:
            try:
                if sys.stdin.isatty() and (not one_shot):
                    _ = input(PS1)

                with contextlib.redirect_stdout(sys.stderr):
                    text = shell.listen(stt_file=stt_file) if stt_file else shell.listen()

                if not text:
                    if one_shot:
                        return 1
                    continue

                t = (text or "").strip()
                if t.lower() in ("exit", "quit", "q"):
                    return 0

                print(f"📝 {t}", file=sys.stderr)

                cmd = (stream_shell_cmd or "").replace("{STT}", t).replace("{STT_STREAM}", t)

                if dry_run:
                    print(cmd)
                    if one_shot:
                        return 0
                    continue

                ok, reason = check_command_safety(cmd, config, dry_run)
                if not ok:
                    if one_shot:
                        return 0 if reason == "dry-run" else 1
                    continue

                out, code, printed = shell.run_command_any(cmd)
                if out.strip() and not printed:
                    print(out, end="", flush=True)
                lines = [l.strip() for l in out.splitlines() if l.strip()]
                if lines and shell.tts and config.get("auto_tts", True):
                    shell.tts.speak(lines[-1][:200])

                if one_shot:
                    return code
            except KeyboardInterrupt:
                print("", file=sys.stderr)
                if one_shot:
                    return 130
                continue
            except EOFError:
                return 0

    if stt_once:
        # Pipeline-friendly mode: transcript to stdout, all prompts/status to stderr
        with contextlib.redirect_stdout(sys.stderr):
            text = shell.listen(stt_file=stt_file) if stt_file else shell.listen()
        if text:
            print(text)
            return 0 if text else 1
        return 1

    if config.get("nlp2cmd_parallel"):
        bin_name = os.environ.get("STTS_NLP2CMD_BIN", "nlp2cmd")
        if rest and rest[0] == bin_name and any("{STT}" in a for a in rest):
            run_mode = ("-r" in rest) or ("--run" in rest)
            auto_confirm = ("--auto-confirm" in rest)

            nlp2cmd_prewarm(config)
            if stt_file:
                text = shell.listen(stt_file=stt_file)
                if stt_only:
                    print(text)
                    return 0 if text else 1
            else:
                with contextlib.redirect_stdout(sys.stderr):
                    text = shell.listen()
            if not text:
                return 1

            translated = nlp2cmd_translate(text, config=config, force=True)
            if not translated:
                cprint(Colors.RED, "❌ nlp2cmd: brak wygenerowanej komendy")
                return 1

            if dry_run:
                print(translated)
                return 0

            if not auto_confirm:
                if not nlp2cmd_confirm(translated):
                    return 0

            ok, reason = check_command_safety(translated, config, dry_run)
            if not ok:
                return 0 if reason == "dry-run" else 1
            out, code, printed = shell.run_command_any(translated)
            if out.strip() and not printed:
                print(out, end="", flush=True)
            return code

    if stt_file:
        # When user provides an explicit command template with {STT}/{STT_STREAM}, treat it as
        # placeholder expansion mode (useful for CI/docker and one-shot runs).
        if rest and any(("{STT}" in a) or ("{STT_STREAM}" in a) for a in rest):
            nlp2cmd_prewarm(config)
            text = shell.listen(stt_file=stt_file)
            if stt_only:
                print(text)
                return 0 if text else 1
            if not text:
                return 1

            expanded = [str(a).replace("{STT}", text).replace("{STT_STREAM}", text) for a in rest]
            cmd = argv_to_cmd(expanded)

            if dry_run:
                print(cmd)
                return 0

            ok, reason = check_command_safety(cmd, config, dry_run)
            if not ok:
                return 0 if reason == "dry-run" else 1

            out, code, printed = shell.run_command_any(cmd)
            if out.strip() and not printed:
                print(out, end="", flush=True)
            lines = [l.strip() for l in out.splitlines() if l.strip()]
            if lines and shell.tts and config.get("auto_tts", True):
                shell.tts.speak(lines[-1][:200])
            return code

        # Default: execute transcript (with optional nlp2cmd translation)
        nlp2cmd_prewarm(config)
        text = shell.listen(stt_file=stt_file)
        if stt_only:
            print(text)
            return 0 if text else 1
        if text:
            if dry_run:
                print(text)
                return 0
            translated = nlp2cmd_translate(text, config=config)
            if translated and nlp2cmd_confirm(translated):
                text = translated
            out, code, printed = shell.run_command_any(text)
            if out.strip() and not printed:
                print(out, end="", flush=True)
            return code
        return 1

    # Pipeline-friendly: allow piping a command into stts and asking for dry-run printing.
    # Example: echo "rm -rf /" | ./stts --dry-run
    if dry_run and (not rest) and (not stt_once) and (not stt_stream_shell) and (not tts_stdin) and (not tts_test_flag) and (not setup) and (not init) and (not sys.stdin.isatty()):
        data = ""
        try:
            data = sys.stdin.read()
        except Exception:
            data = ""
        lines = [l.strip() for l in (data or "").splitlines() if l.strip()]
        cmd = lines[-1] if lines else ""
        if not cmd:
            return 1
        try:
            print(cmd)
        except BrokenPipeError:
            return 0
        return 0

    if not rest:
        shell.run()
        return 0

    bin_name = os.environ.get("STTS_NLP2CMD_BIN", "nlp2cmd")
    if rest and rest[0] == bin_name and (not sys.stdin.isatty()) and any(a == "stdin" for a in rest[1:]):
        run_mode = ("-r" in rest) or ("--run" in rest)
        auto_confirm = ("--auto-confirm" in rest)

        data = ""
        try:
            data = sys.stdin.read()
        except Exception:
            data = ""
        lines = [l.strip() for l in (data or "").splitlines() if l.strip()]
        text = lines[-1] if lines else ""
        if not text:
            cprint(Colors.RED, "❌ stdin: brak tekstu")
            return 1

        nlp2cmd_prewarm_force()
        translated = nlp2cmd_translate(text, config=config, force=True)
        if not translated:
            cprint(Colors.RED, "❌ nlp2cmd: brak wygenerowanej komendy")
            return 1

        if not run_mode:
            print(translated)
            return 0

        if dry_run:
            print(translated)
            return 0

        if not auto_confirm:
            if not nlp2cmd_confirm(translated):
                return 0

        ok, reason = check_command_safety(translated, config, dry_run)
        if not ok:
            return 0 if reason == "dry-run" else 1
        out, code, printed = shell.run_command_any(translated)
        if out.strip() and not printed:
            print(out, end="", flush=True)
        return code

    if dry_run:
        # pipeline-friendly: all logs/prompts to stderr; stdout contains ONLY the final command
        with contextlib.redirect_stdout(sys.stderr):
            expanded = expand_placeholders(rest, shell, config, stt_file=stt_file)
            if expanded is None:
                return 1
            cmd = argv_to_cmd(expanded)
            _, reason = check_command_safety(cmd, config, dry_run=True)
        if reason == "dry-run":
            try:
                print(cmd)
            except BrokenPipeError:
                return 0
            return 0

    expanded = expand_placeholders(rest, shell, config, stt_file=stt_file)
    if expanded is None:
        return 1

    cmd = argv_to_cmd(expanded)

    # Safety check
    ok, reason = check_command_safety(cmd, config, dry_run)
    if not ok:
        return 0 if reason == "dry-run" else 1

    out, code, printed = shell.run_command_any(cmd)
    if out.strip() and not printed:
        print(out, end="", flush=True)
    lines = [l.strip() for l in out.splitlines() if l.strip()]
    if lines and shell.tts and config.get("auto_tts", True):
        shell.tts.speak(lines[-1][:200])
    return code


if __name__ == "__main__":
    raise SystemExit(main())
