#!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 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
from pathlib import Path
from dataclasses import dataclass
from typing import Optional, List, Tuple


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,
    "tts_voice": "pl",
    "language": "pl",
    "timeout": 5,
    "auto_tts": 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": -45.0,
    "safe_mode": False,
    "piper_auto_install": True,
    "piper_auto_download": True,
    "piper_release_tag": "2023.11.14-2",
    "piper_voice_version": "v1.0.0",
}


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_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_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")
    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


def detect_system() -> SystemInfo:
    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
    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:
        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
    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:
        pass

    return 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,
    )


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 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"):
        self.model = model
        self.language = language

    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 install(cls, info: SystemInfo) -> 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:
            cprint(Colors.GREEN, "✅ whisper.cpp already installed!")
            return True
        try:
            if not (whisper_dir / "Makefile").exists():
                subprocess.run(
                    ["git", "clone", "https://github.com/ggerganov/whisper.cpp", str(whisper_dir)],
                    check=True,
                )
            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

    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:
            result = subprocess.run(
                [whisper_bin, "-m", str(model_path), "-l", self.language, "-f", audio_path, "-nt"],
                capture_output=True,
                text=True,
                timeout=120,
            )
            return result.stdout.strip()
        except Exception as e:
            cprint(Colors.RED, f"❌ Transcription 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"):
        self.voice = voice

    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:
            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
        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:
        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()
        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)
        except Exception:
            return


STT_PROVIDERS = {"whisper_cpp": WhisperCppSTT}
TTS_PROVIDERS = {"espeak": EspeakTTS, "piper": PiperTTS}


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_cls = WhisperCppSTT
    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_provider"] = "whisper_cpp"
    config["stt_model"] = model

    if input(f"Download model {model} now? (y/n): ").strip().lower() == "y":
        stt_cls.download_model(model)

    es_ok, es_reason = EspeakTTS.is_available(info)
    pi_ok, pi_reason = PiperTTS.is_available(info)
    print("TTS:")
    print(f"  1) espeak ({'OK' if es_ok else es_reason})")
    print(f"  2) piper  ({'OK' if pi_ok else pi_reason})")
    tts_sel = input("Wybierz TTS [1/2] (ENTER=1): ").strip() or "1"

    if tts_sel == "2":
        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", ""))
        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:
        config["tts_provider"] = "espeak" if es_ok else None
        if not es_ok:
            cprint(Colors.YELLOW, "Install espeak: sudo apt install espeak")

    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
]

# 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 nlp2cmd_translate(text: str) -> Optional[str]:
    if os.environ.get("STTS_NLP2CMD_ENABLED", "0").strip() not in ("1", "true", "yes", "y"):
        return None
    bin_name = os.environ.get("STTS_NLP2CMD_BIN", "nlp2cmd")
    args = shlex.split(os.environ.get("STTS_NLP2CMD_ARGS", "-r"))
    if not shutil.which(bin_name):
        cprint(Colors.YELLOW, f"⚠️  {bin_name} nie znaleziony. Zainstaluj: pip install nlp2cmd")
        return None
    try:
        res = subprocess.run([bin_name, *args, text], capture_output=True, text=True, timeout=60)
        out = (res.stdout or "") + (res.stderr or "")
        lines = [l.strip() for l in out.splitlines() if l.strip()]
        if not lines:
            return None

        # heuristic: first plausible command-like line
        for l in lines:
            if l.startswith("```"):
                continue
            if l.startswith("📊"):
                continue
            if l.lower().startswith("time:"):
                continue
            if l.startswith("$"):
                candidate = l.lstrip("$").strip()
                # Filter out SQL
                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
                return candidate
            # skip SQL-like lines entirely
            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 any(x in l for x in (" ", ";", "|", "&&", "||", "docker", "kubectl", "git")):
                return l
            if l and all(ch.isalnum() or ch in ("-", "_", ".") for ch in l):
                return l
        return lines[0]
    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"


def _has_pexpect() -> bool:
    try:
        import pexpect  # noqa: F401

        return True
    except Exception:
        return False


class VoiceShell:
    def __init__(self, config: dict):
        self.config = config
        self.info = detect_system()
        self.stt = self._init_stt()
        self.tts = self._init_tts()

        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, _ = cls.is_available(self.info)
            if available:
                return cls(model=self.config.get("stt_model"), language=self.config.get("language", "pl"))
        return None

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

        if provider == "piper" and self.config.get("piper_auto_install", True) and not PiperTTS.find_piper_bin():
            try:
                PiperTTS.install_local(self.info, self.config.get("piper_release_tag", "2023.11.14-2"))
            except Exception:
                pass

        if provider in TTS_PROVIDERS:
            cls = TTS_PROVIDERS[provider]
            available, reason = cls.is_available(self.info)
            if available:
                inst = cls(voice=voice)
                if provider == "piper":
                    try:
                        if not getattr(inst, "_resolve_model")():
                            if self.config.get("piper_auto_download", True):
                                PiperTTS.download_voice(voice, self.config.get("piper_voice_version", "v1.0.0"))
                            if not getattr(inst, "_resolve_model")():
                                available, reason = False, "piper model not found"
                    except Exception:
                        available, reason = False, "piper model not found"
                if available:
                    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)
        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:
            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:
            cprint(Colors.GREEN, f"✅ \"{text}\" ({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."""
        import shlex
        try:
            # Użyj Popen z stdout=PIPE do strumieniowania
            process = subprocess.Popen(
                cmd if isinstance(cmd, list) else shlex.split(cmd),
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                bufsize=1,  # line buffering
                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.
        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, False
        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", ["-lc", 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}")
        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)
                    cmd = self.listen()
                    if not cmd:
                        continue
                    translated = nlp2cmd_translate(cmd)
                    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 parse_args(argv: List[str]):
    stt_file = None
    stt_only = False
    stt_once = False
    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
    rest: List[str] = []

    it = iter(argv)
    for a in it:
        if a == "--stt-file":
            stt_file = next(it, None)
        elif a == "--stt-only":
            stt_only = True
        elif a == "--stt-once":
            stt_once = True
        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 in ("--help", "-h"):
            help_ = True
        else:
            rest.append(a)
    return stt_file, stt_only, stt_once, setup, init, tts_provider, tts_voice, tts_stdin, tts_test, tts_test_text, install_piper, download_piper_voice, help_, dry_run, safe_mode, 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 _resolve_at_token(token: str, config: dict) -> Optional[str]:
    if token == "TTSProvider":
        return config.get("tts_provider")
    if token == "STTProvider":
        return config.get("stt_provider")
    return None


def expand_placeholders(rest: List[str], shell: "VoiceShell", config: dict) -> Optional[List[str]]:
    stt_text: Optional[str] = None

    need_stt = any("{STT}" in a for a in rest)
    if need_stt:
        stt_text = shell.listen()
        if not stt_text:
            cprint(Colors.RED, "❌ Nie rozpoznano mowy (STT)")
            return None
        stt_text = shlex.quote(stt_text)

    out: List[str] = []
    for a in rest:
        if a.startswith("@[") and a.endswith("]"):
            key = a[2:-1].strip()
            resolved = _resolve_at_token(key, config)
            if resolved is not None:
                out.append(str(resolved))
                continue
        if stt_text and "{STT}" in a:
            out.append(a.replace("{STT}", stt_text))
            continue
        out.append(a)
    return out


def quick_init(spec: str) -> dict:
    """Quick non-interactive setup: --init whisper_cpp:tiny or --init whisper_cpp"""
    config = load_config()
    parts = spec.split(":")
    provider = parts[0].strip()
    model = parts[1].strip() if len(parts) > 1 else None

    if provider in ("whisper_cpp", "whisper"):
        config["stt_provider"] = "whisper_cpp"
        config["stt_model"] = model or "tiny"
        # Auto-download model if not present
        stt_cls = WhisperCppSTT
        info = detect_system()
        available, _ = stt_cls.is_available(info)
        if not available:
            cprint(Colors.YELLOW, "Installing whisper.cpp...")
            stt_cls.install(info)
        model_path = MODELS_DIR / "whisper.cpp" / f"ggml-{config['stt_model']}.bin"
        if not model_path.exists():
            cprint(Colors.YELLOW, f"Downloading model {config['stt_model']}...")
            stt_cls.download_model(config["stt_model"])
    else:
        config["stt_provider"] = provider
        if model:
            config["stt_model"] = model

    # Default TTS to espeak if available
    if shutil.which("espeak") or shutil.which("espeak-ng"):
        config["tts_provider"] = "espeak"

    save_config(config)
    cprint(Colors.GREEN, f"✅ Initialized: STT={config['stt_provider']}:{config.get('stt_model')} TTS={config.get('tts_provider')}")
    return config


def apply_quick_tts(config: dict, tts_provider: Optional[str], tts_voice: Optional[str]) -> dict:
    if tts_provider is not None:
        v = tts_provider.strip()
        config["tts_provider"] = v or None
        if v in ("espeak", "espeak-ng"):
            if not (shutil.which("espeak") or shutil.which("espeak-ng")):
                cprint(Colors.YELLOW, "⚠️  espeak nie znaleziony. Zainstaluj: sudo apt install espeak")
            config["tts_provider"] = "espeak"

    if tts_voice is not None:
        vv = tts_voice.strip()
        config["tts_voice"] = vv or config.get("tts_voice", "pl")

    return config


def check_command_safety(cmd: str, config: dict, dry_run: bool = False) -> Tuple[bool, str]:
    """Check if command is safe to run. Returns (ok_to_run, message)."""
    # Check dangerous patterns
    is_dangerous, reason = is_dangerous_command(cmd)
    if is_dangerous:
        cprint(Colors.RED, f"🚫 ZABLOKOWANO niebezpieczną komendę: {cmd[:60]}...")
        cprint(Colors.YELLOW, f"   Powód: {reason}")
        return False, reason

    # Dry-run mode: don't execute
    if dry_run:
        return False, "dry-run"

    # Safe mode: always confirm
    if config.get("safe_mode", False):
        cprint(Colors.YELLOW, f"🔒 SAFE MODE: {cmd}")
        ans = input("Uruchomić? (y/n): ").strip().lower()
        if ans != "y":
            return False, "user_cancelled"

    return True, ""


def tts_from_stdin(shell: "VoiceShell") -> int:
    data = sys.stdin.read()
    # passthrough: keep pipeline output unchanged
    if data:
        sys.stdout.write(data)

    lines = [l.strip() for l in data.splitlines() if l.strip()]
    if not lines:
        return 0

    text = lines[-1][:200]
    if not shell.tts:
        print("[stts] TTS not available (check tts_provider/tts_voice and provider binaries)", file=sys.stderr)
        return 0
    try:
        print(f"[stts] TTS: provider={getattr(shell.tts, 'name', 'unknown')} voice={getattr(shell.tts, 'voice', '')}", file=sys.stderr)
    except Exception:
        pass
    # Providers may print diagnostics via cprint(); keep pipeline stdout clean.
    with contextlib.redirect_stdout(sys.stderr):
        shell.tts.speak(text)
    return 0


def main():
    config = load_config()
    stt_file, stt_only, stt_once, setup, init, tts_provider, tts_voice, tts_stdin, tts_test_flag, tts_test_text, install_piper, download_piper_voice, help_, dry_run, safe_mode, rest = parse_args(sys.argv[1:])

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

    if help_:
        print(__doc__)
        print("\nOpcje bezpieczeństwa:")
        print("  --dry-run      Pokaż komendę bez wykonania")
        print("  --safe-mode    Zawsze pytaj przed wykonaniem")
        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_ID  Pobierz model i config 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_PIPER_AUTO_INSTALL=1  Auto-install piper binarki (local)")
        print("  STTS_PIPER_AUTO_DOWNLOAD=1 Auto-download modelu piper dla tts_voice")
        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)

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

    if stt_file:
        text = shell.listen(stt_file=stt_file)
        if stt_only:
            print(text)
            return 0 if text else 1
        if text:
            translated = nlp2cmd_translate(text)
            if translated and nlp2cmd_confirm(translated):
                text = translated
            out, code, interactive = shell.run_command_any(text)
            if out.strip():
                print(out, end="", flush=True)
            return code
        return 1

    if not rest:
        shell.run()
        return 0

    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)
            if expanded is None:
                return 1
            cmd = " ".join(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)
    if expanded is None:
        return 1

    cmd = " ".join(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, interactive = shell.run_command_any(cmd)
    if out.strip():
        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())
