#!/usr/bin/env python3
"""헤이 자비스 — 호출어(openWakeWord/Porcupine) + Google STT + Gemini + TTS

호출어
------
- WAKE_MODE=jarvis  → openWakeWord (영어, jarvis.env OWW_WAKE_MODEL)
  예: alexa(쉬움), hey_jarvis, hey_mycroft, timer, weather
- WAKE_MODE=korean → Picovoice Porcupine 한국어 (로컬, STT 호출어 없음)
  필요: jarvis.env 의 PICOVOICE_ACCESS_KEY
  모델/키워드: ~/jarvis_porcupine/ (없으면 Access Key로 .ppn 자동 생성)

영구 메모리 (로컬 파일, 웹 제미나이 기억과 무관)
----------------------------------------------
파일: ~/projects/jarvis/jarvis_memory.json
명령:
  기억해 아들은 맵기 싫어해   → 한 줄 저장 (말투/사실 모두 가능)
  잊어 맵기                 → 그 단어 들어간 기억 삭제
  기억 뭐 있어? / 기억 목록   → 저장된 목록 읽기
  기억 다 지워               → 전체 삭제
질문할 때마다 system 지침에 붙여 보내므로 입력 토큰에 포함됨(짧게 유지).

대화 로그 (질문/답변 분리)
--------------------------
- ~/jarvis_logs/questions.json
- ~/jarvis_logs/answers.json
동일 turn_id 로 짝을 맞출 수 있음.

API / 문서
---------
- Gemini: https://ai.google.dev/gemini-api/docs
  키: https://aistudio.google.com/apikey
  요금: https://ai.google.dev/gemini-api/docs/pricing
- Porcupine: https://console.picovoice.ai/ (Access Key)
- STT: SpeechRecognition Google Web Speech(무료) 또는 Cloud Speech-to-Text
  전환: ~/projects/jarvis/jarvis_runtime.json 의 "stt_backend": "web" | "cloud" (재시작 불필요)
- TTS: gemini-2.5-flash-preview-tts (기본) 또는 edge-tts (무료 백업)
"""

from __future__ import annotations

import asyncio
import audioop
import base64
import contextlib
import html
import io
import json
import logging
import os
import re
import subprocess
import threading
import time
import uuid
import urllib.error
import urllib.parse
import urllib.request
import wave
from concurrent.futures import Future, ThreadPoolExecutor
from datetime import datetime, timezone
from ctypes import CFUNCTYPE, c_char_p, c_int, cdll

os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide"

import warnings

warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", message=".*automatic function calling.*")

import edge_tts
import google.generativeai as genai
from google import genai as genai_client
from google.genai import types as genai_types
import numpy as np
import pyaudio
import pygame
import speech_recognition as sr
from openwakeword.model import Model

# ALSA 경고 숨기기 (콜백은 GC되면 segfault 나므로 전역으로 유지)
ERROR_HANDLER_FUNC = CFUNCTYPE(None, c_char_p, c_int, c_char_p, c_int, c_char_p)
_alsa_error_handler = None


def _py_error_handler(filename, line, function, err, fmt):
    pass


try:
    asound = cdll.LoadLibrary("libasound.so.2")
    _alsa_error_handler = ERROR_HANDLER_FUNC(_py_error_handler)
    asound.snd_lib_error_set_handler(_alsa_error_handler)
except Exception:
    pass

# ==========================================
# 설정 (~/projects/jarvis/jarvis.env 가 우선)
# ==========================================
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) or "."
ENV_PATH = os.path.join(BASE_DIR, "jarvis.env")
MEMORY_PATH = os.path.join(BASE_DIR, "jarvis_memory.json")
MEMORY_MAX = 50
HOOKS_DIR = os.path.join(BASE_DIR, "jarvis_hooks")
LOG_DIR = os.path.join(BASE_DIR, "jarvis_logs")
QUESTIONS_LOG = os.path.join(LOG_DIR, "questions.json")
ANSWERS_LOG = os.path.join(LOG_DIR, "answers.json")
USAGE_SUMMARY_PATH = os.path.join(LOG_DIR, "usage_summary.json")
RUNTIME_CONFIG_PATH = os.path.join(BASE_DIR, "jarvis_runtime.json")
_runtime_mtime: float = 0.0


def load_env_file(path: str) -> None:
    """KEY=VALUE 형식. jarvis.env → os.environ."""
    if not os.path.isfile(path):
        return
    with open(path, encoding="utf-8") as f:
        for raw in f:
            line = raw.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            key, val = line.split("=", 1)
            key = key.strip()
            val = val.strip().strip('"').strip("'")
            if key:
                os.environ[key] = val


load_env_file(ENV_PATH)


def _runtime_defaults() -> dict:
    return {
        "_help": "수정 저장하면 재시작 없이 자동 적용됩니다. stt_backend: web(무료) | cloud(유료 Cloud STT).",
        "wake_threshold": float(os.environ.get("WAKE_THRESHOLD", "0.27")),
        "wake_min_frames": int(os.environ.get("WAKE_MIN_FRAMES", "2")),
        "wake_strong_score": float(os.environ.get("WAKE_STRONG_SCORE", "0.52")),
        "min_wake_interval_sec": float(os.environ.get("MIN_WAKE_INTERVAL_SEC", "10")),
        "stt_pause_threshold": float(os.environ.get("STT_PAUSE_THRESHOLD", "0.5")),
        "stt_non_speaking_sec": float(os.environ.get("STT_NON_SPEAKING_SEC", "0.5")),
        "stt_mic_gain": float(os.environ.get("STT_MIC_GAIN", "1.35")),
        "stt_energy_threshold": float(os.environ.get("STT_ENERGY_THRESHOLD", "280")),
        "stt_phrase_limit_sec": float(os.environ.get("STT_PHRASE_LIMIT_SEC", "12")),
        "stt_max_record_sec": float(os.environ.get("STT_MAX_RECORD_SEC", "15")),
        "stt_min_pause_sec": float(os.environ.get("STT_MIN_PAUSE_SEC", "1.0")),
        "stt_silence_peak_ratio": float(os.environ.get("STT_SILENCE_PEAK_RATIO", "0.85")),
        "stt_speech_start_ratio": float(os.environ.get("STT_SPEECH_START_RATIO", "1.35")),
        "stt_phrase_threshold": float(os.environ.get("STT_PHRASE_THRESHOLD", "0.05")),
        "stt_timeout_sec": float(os.environ.get("STT_TIMEOUT_SEC", "5")),
        "chat_history_max": int(os.environ.get("CHAT_HISTORY_MAX", "6")),
        "stt_backend": os.environ.get("STT_BACKEND", "web").strip().lower() or "web",
    }


_RUNTIME_FLOAT = {
    "wake_threshold": "WAKE_THRESHOLD",
    "wake_strong_score": "WAKE_STRONG_SCORE",
    "min_wake_interval_sec": "MIN_WAKE_INTERVAL_SEC",
    "stt_pause_threshold": "STT_PAUSE_THRESHOLD",
    "stt_non_speaking_sec": "STT_NON_SPEAKING_SEC",
    "stt_mic_gain": "STT_MIC_GAIN",
    "stt_energy_threshold": "STT_ENERGY_THRESHOLD",
    "stt_phrase_limit_sec": "STT_PHRASE_LIMIT_SEC",
    "stt_max_record_sec": "STT_MAX_RECORD_SEC",
    "stt_min_pause_sec": "STT_MIN_PAUSE_SEC",
    "stt_silence_peak_ratio": "STT_SILENCE_PEAK_RATIO",
    "stt_speech_start_ratio": "STT_SPEECH_START_RATIO",
    "stt_phrase_threshold": "STT_PHRASE_THRESHOLD",
    "stt_timeout_sec": "STT_TIMEOUT_SEC",
}
_RUNTIME_INT = {
    "wake_min_frames": "WAKE_MIN_FRAMES",
    "chat_history_max": "CHAT_HISTORY_MAX",
}
_RUNTIME_STR = {
    "stt_backend": "STT_BACKEND",
}


def ensure_runtime_config_file() -> None:
    defaults = _runtime_defaults()
    if not os.path.isfile(RUNTIME_CONFIG_PATH):
        with open(RUNTIME_CONFIG_PATH, "w", encoding="utf-8") as f:
            json.dump(defaults, f, ensure_ascii=False, indent=2)
        print(f"실시간 설정 파일 생성: {RUNTIME_CONFIG_PATH}")
        return
    try:
        with open(RUNTIME_CONFIG_PATH, encoding="utf-8") as f:
            data = json.load(f)
    except (OSError, json.JSONDecodeError):
        return
    if not isinstance(data, dict):
        return
    missing = {k: v for k, v in defaults.items() if k not in data}
    if not missing:
        return
    data.update(missing)
    with open(RUNTIME_CONFIG_PATH, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
    print(f"실시간 설정 키 추가: {', '.join(missing)}")


def load_runtime_config_if_changed(verbose: bool = False) -> bool:
    """jarvis_runtime.json 변경 시 즉시 적용 (재시작 불필요)."""
    global _runtime_mtime
    if not os.path.isfile(RUNTIME_CONFIG_PATH):
        return False
    try:
        mtime = os.path.getmtime(RUNTIME_CONFIG_PATH)
    except OSError:
        return False
    if mtime == _runtime_mtime:
        return False
    try:
        with open(RUNTIME_CONFIG_PATH, encoding="utf-8") as f:
            data = json.load(f)
    except (OSError, json.JSONDecodeError) as e:
        print(f"jarvis_runtime.json 읽기 실패: {e}")
        return False
    if not isinstance(data, dict):
        return False
    applied: list[str] = []
    g = globals()
    for key, var in _RUNTIME_FLOAT.items():
        if key in data and data[key] is not None:
            g[var] = float(data[key])
            applied.append(f"{key}={g[var]}")
    for key, var in _RUNTIME_INT.items():
        if key in data and data[key] is not None:
            g[var] = int(data[key])
            applied.append(f"{key}={g[var]}")
    if "chat_history_max" in data and data["chat_history_max"] is not None:
        max_h = int(data["chat_history_max"])
        if len(_session_history) > max_h:
            _session_history[:] = _session_history[-max_h:]
    for key, var in _RUNTIME_STR.items():
        if key in data and data[key] is not None:
            val = str(data[key]).strip().lower()
            g[var] = val
            applied.append(f"{key}={val}")
    _runtime_mtime = mtime
    if verbose and applied:
        print(f"[실시간 설정 적용] {', '.join(applied)}")
    return True


GOOGLE_API_KEY = os.environ.get(
    "GOOGLE_API_KEY",
    "AIzaSyBHMoObQ_yYW2njaGenc3_j4CuCIFAaadA",
)

GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash")
GEMINI_MAX_OUTPUT_TOKENS = int(os.environ.get("GEMINI_MAX_OUTPUT_TOKENS", "512"))
GEMINI_VOICE_MAX_TOKENS = int(os.environ.get("GEMINI_VOICE_MAX_TOKENS", "256"))
GEMINI_STREAM = os.environ.get("GEMINI_STREAM", "1") == "1"
TTS_CHUNK_CHARS = int(os.environ.get("TTS_CHUNK_CHARS", "180"))
PRICE_INPUT_PER_M = 0.30
PRICE_OUTPUT_PER_M = 2.50
USD_KRW = float(os.environ.get("USD_KRW", "1400"))

WAKE_MODE = os.environ.get("WAKE_MODE", "jarvis").strip().lower()
LANG_MODE = os.environ.get("LANG_MODE", "ko").strip().lower()

WAKE_THRESHOLD = float(os.environ.get("WAKE_THRESHOLD", "0.27"))
WAKE_MIN_FRAMES = int(os.environ.get("WAKE_MIN_FRAMES", "2"))
WAKE_STRONG_SCORE = float(os.environ.get("WAKE_STRONG_SCORE", "0.52"))
def _is_oww_model_path(spec: str) -> bool:
    return "/" in spec or spec.endswith((".tflite", ".onnx"))


_oww_raw = os.environ.get("OWW_WAKE_MODEL", "alexa").strip()
_oww_parts = [p.strip() for p in _oww_raw.split(",") if p.strip()]
OWW_WAKE_PATHS: list[str] = []
OWW_WAKE_LABELS: list[str] = []
for _spec in _oww_parts:
    if _is_oww_model_path(_spec):
        OWW_WAKE_PATHS.append(_spec)
        OWW_WAKE_LABELS.append(os.path.splitext(os.path.basename(_spec))[0])
    else:
        name = _spec.lower()
        OWW_WAKE_PATHS.append(name)
        OWW_WAKE_LABELS.append(name)

OWW_INFERENCE_FRAMEWORK = os.environ.get("OWW_INFERENCE_FRAMEWORK", "").strip().lower()
if not OWW_INFERENCE_FRAMEWORK:
    OWW_INFERENCE_FRAMEWORK = (
        "tflite"
        if any(s.endswith(".tflite") for s in _oww_parts if _is_oww_model_path(s))
        else "onnx"
    )
OWW_WAKE_HINT = os.environ.get("OWW_WAKE_HINT", "").strip()
_mic_env = os.environ.get("STT_MIC_INDEX", os.environ.get("MIC_INDEX", "")).strip()
MIC_INDEX = int(_mic_env) if _mic_env else None
STT_MIC_INDEX: int | None = MIC_INDEX
SPEAKER_VOLUME = 100
WAKE_FRAME = 1280
WAKE_DEBUG = os.environ.get("WAKE_DEBUG", "0") == "1"
WAKE_COOLDOWN_SEC = float(os.environ.get("WAKE_COOLDOWN_SEC", "3.0"))
MIN_WAKE_INTERVAL_SEC = float(os.environ.get("MIN_WAKE_INTERVAL_SEC", "10.0"))
LISTEN_FAIL_COOLDOWN_SEC = float(os.environ.get("LISTEN_FAIL_COOLDOWN_SEC", "5.0"))
_last_wake_at = 0.0
STT_TIMEOUT_SEC = float(os.environ.get("STT_TIMEOUT_SEC", "5"))
STT_PHRASE_LIMIT_SEC = float(os.environ.get("STT_PHRASE_LIMIT_SEC", "12"))
STT_MAX_RECORD_SEC = float(os.environ.get("STT_MAX_RECORD_SEC", "15"))
STT_MIN_PAUSE_SEC = float(os.environ.get("STT_MIN_PAUSE_SEC", "1.0"))
STT_SILENCE_PEAK_RATIO = float(os.environ.get("STT_SILENCE_PEAK_RATIO", "0.85"))
STT_SPEECH_START_RATIO = float(os.environ.get("STT_SPEECH_START_RATIO", "1.35"))
STT_SMART_END = os.environ.get("STT_SMART_END", "1") == "1"
STT_PHRASE_THRESHOLD = float(os.environ.get("STT_PHRASE_THRESHOLD", "0.05"))
STT_PAUSE_THRESHOLD = float(os.environ.get("STT_PAUSE_THRESHOLD", "1.0"))
STT_NON_SPEAKING_SEC = float(os.environ.get("STT_NON_SPEAKING_SEC", "0.55"))
STT_NOISY_THRESHOLD = float(os.environ.get("STT_NOISY_THRESHOLD", "380"))
STT_AMBIENT_SEC = float(os.environ.get("STT_AMBIENT_SEC", "0.3"))
STT_POST_BEEP_DELAY_SEC = float(os.environ.get("STT_POST_BEEP_DELAY_SEC", "0.15"))
STT_RECAL_BEFORE_LISTEN = os.environ.get("STT_RECAL_BEFORE_LISTEN", "0") == "1"
STT_RETRY_MAX = int(os.environ.get("STT_RETRY_MAX", "1"))
STT_MIC_GAIN = float(os.environ.get("STT_MIC_GAIN", "1.35"))
STT_ENERGY_THRESHOLD = float(os.environ.get("STT_ENERGY_THRESHOLD", "280"))
STT_QUIET_MODE = os.environ.get("STT_QUIET_MODE", "1") == "1"
STT_BACKEND = os.environ.get("STT_BACKEND", "web").strip().lower() or "web"
STT_CLOUD_MODEL = os.environ.get("STT_CLOUD_MODEL", "command_and_search").strip()
STT_CLOUD_FALLBACK = os.environ.get("STT_CLOUD_FALLBACK", "1") == "1"
CHAT_HISTORY_MAX = int(os.environ.get("CHAT_HISTORY_MAX", "6"))
PULSE_SOURCE = os.environ.get("PULSE_SOURCE", "").strip()
YT_DLP_BIN = os.environ.get(
    "YT_DLP_BIN", os.path.join(BASE_DIR, "jarvis_env", "bin", "yt-dlp")
)
YOUTUBE_API_KEY = (
    os.environ.get("YOUTUBE_API_KEY", "").strip() or GOOGLE_API_KEY.strip()
)
YOUTUBE_SEARCH_MODE = os.environ.get("YOUTUBE_SEARCH_MODE", "auto").strip().lower()

OWW_WAKE_HINTS = {
    "alexa": "「Alexa」(알렉사)",
    "hey_jarvis": "「Hey Jarvis」",
    "hey_mycroft": "「Hey Mycroft」",
    "hey_rhasspy": "「Hey Rhasspy」",
    "timer": "「timer」",
    "weather": "「weather」",
}

PICOVOICE_ACCESS_KEY = os.environ.get("PICOVOICE_ACCESS_KEY", "").strip()
PORCUPINE_DIR = os.environ.get(
    "PORCUPINE_DIR", os.path.join(BASE_DIR, "jarvis_porcupine")
)
PORCUPINE_MODEL_PATH = os.environ.get(
    "PORCUPINE_MODEL_PATH",
    os.path.join(PORCUPINE_DIR, "porcupine_params_ko.pv"),
)
PORCUPINE_KEYWORD_PATH = os.environ.get("PORCUPINE_KEYWORD_PATH", "").strip()
PORCUPINE_PHRASES = os.environ.get("PORCUPINE_PHRASES", "자비스,헤이 자비스")
PORCUPINE_SENSITIVITY = float(os.environ.get("PORCUPINE_SENSITIVITY", "0.55"))
PORCUPINE_LANGUAGE = os.environ.get("PORCUPINE_LANGUAGE", "ko").strip().lower()
PORCUPINE_MODEL_URL = (
    "https://github.com/Picovoice/porcupine/raw/master/lib/common/"
    "porcupine_params_ko.pv"
)

if LANG_MODE.startswith("en"):
    STT_LANGUAGE = "en-US"
    DEFAULT_EDGE_TTS_VOICE = "en-US-JennyNeural"
    DEFAULT_GEMINI_TTS_VOICE = "Kore"
    BASE_INSTRUCTION = (
        "You are Jarvis. Answer clearly and briefly in English. "
        "Help with cooking, schedule, and daily questions. "
        "Use [permanent memory] when present."
    )
else:
    STT_LANGUAGE = "ko-KR"
    DEFAULT_EDGE_TTS_VOICE = "ko-KR-SunHiNeural"
    DEFAULT_GEMINI_TTS_VOICE = "Kore"
    BASE_INSTRUCTION = (
        "너는 자비스야. 사용자는 마이크로 말했고 입력은 음성인식 결과야. "
        "소리를 못 듣거나 글자로만 대화한다고 말하지 마. "
        "한국어로 짧고 명확하게 대답해. "
        "말투는 나긋하고 부드럽게. "
        "답변은 말로 읽기 좋게 2~4문장으로 끝맺음까지 완결하게. "
        "레시피도 4~5줄 이내지만 중간에 끊지 말고 끝까지. "
        "불필요한 인사·서론(알겠습니다 등) 생략하고 본론부터. "
        "날씨는 기온·맑음·비 정도만 한두 문장으로 끝까지. "
        "실시간 조회 불가 설명은 생략하고 추정만 짧게. "
        "요리·일정·일상 질문에 실용적으로 도와줘. "
        "유튜브·노래 재생은 로컬에서 처리된다. "
        "노래를 찾았다/못 찾았다거나 유튜브를 검색했다고 말하지 마. "
        "재생은 사용자가 「OOO 틀어줘」라고 말하면 된다고만 짧게 안내해. "
        "아래 [영구 기억]이 있으면 사실·말투·취향을 반드시 반영해. "
        "이전 대화 맥락이 함께 전달되면 반드시 참고해. "
        "집 기기는 도구로만 제어한다. 잡담·지식(사자 호랑이 등)은 도구 없이 답한다. "
        "한 문장에 장면이면 도구를 여러 개 호출해도 된다. "
        "드라마·영화 볼게 → 티비 ON + 거실/메인 조명 OFF. "
        "잘게·자야지 → 관련 조명 OFF, 켜져 있으면 티비도 OFF. "
        "더워·땀나·미치겠네(집 맥락) → 에어컨 turn_on 후 통보. "
        "절대 되묻지 마(켤까요 금지). 애매한 장난·투정은 기기 건드리지 말고 말로만. "
        "꺼·켜·맞춰면 명령이다. 방 이름 없으면 거실 기본 에어컨·조명·티비. "
        "스냅샷에 없거나 스위치 꺼진 기기는 만들지 말고 없다고만 말해. "
        "티비는 전원만(채널·앱 검색 금지). 실행 결과를 한두 문장으로 말해."
    )

if WAKE_MODE in ("korean", "ko", "hangul", "한글", "porcupine"):
    WAKE_PROMPT = "'자비스' 또는 '헤이 자비스'라고 불러보세요."
else:
    if OWW_WAKE_HINT:
        wake_hint = f"「{OWW_WAKE_HINT}」"
    else:
        hints = [OWW_WAKE_HINTS.get(m, m) for m in OWW_WAKE_LABELS]
        wake_hint = " 또는 ".join(hints)
    WAKE_PROMPT = (
        wake_hint
        + "라고 **한 번만** 불러요. 삐 소리 후 바로 질문. "
        "연속으로 부르면 삐만 여러 번 나와요."
    )

TTS_BACKEND = os.environ.get("TTS_BACKEND", "edge").strip().lower()
TTS_MODEL = os.environ.get("TTS_MODEL", "gemini-2.5-flash-preview-tts")
TTS_VOICE = os.environ.get(
    "TTS_VOICE",
    DEFAULT_GEMINI_TTS_VOICE if TTS_BACKEND == "gemini" else DEFAULT_EDGE_TTS_VOICE,
)
TTS_RATE = os.environ.get("TTS_RATE", "+10%")
TTS_PITCH = os.environ.get("TTS_PITCH", "+0Hz")
TTS_STYLE = os.environ.get(
    "TTS_STYLE",
    "나긋하고 부드럽게, 조금 빠른 속도로 말해줘:",
)
TTS_SAMPLE_RATE = int(os.environ.get("TTS_SAMPLE_RATE", "24000"))
TTS_PLAY_LATENCY_MS = int(os.environ.get("TTS_PLAY_LATENCY_MS", "40"))

_gemini_tts_client: genai_client.Client | None = None
_gemini_text_client: genai_client.Client | None = None

if not GOOGLE_API_KEY or "여기에_" in GOOGLE_API_KEY:
    raise SystemExit(
        "제미나이 API 키를 설정하세요.\n"
        "  1) jarvis.env 에 GOOGLE_API_KEY=... 또는\n"
        "  2) jarvis.py / export GOOGLE_API_KEY"
    )

genai.configure(api_key=GOOGLE_API_KEY)
logging.getLogger("google.genai").setLevel(logging.ERROR)

model = None  # type: ignore
chat = None  # type: ignore
_session_history: list[tuple[str, str]] = []
_stt_ambient_calibrated = False
_stt_noisy_background = False

_mixer_ready = False
PULSE_SINK = os.environ.get("PULSE_SINK", "").strip()
_media_proc: subprocess.Popen | None = None
_media_lock = threading.RLock()
_youtube_pending: tuple[str, str | None] | None = None


_tts_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="jarvis-tts")

_speech_active = False
_speech_interruptible = False
_speech_interrupt = threading.Event()
_pending_interrupt_listen = False
_tts_play_proc: subprocess.Popen | None = None
_tts_play_lock = threading.Lock()
_wake_interrupt_ctx: dict = {}
_interrupt_watcher_stop = threading.Event()
_wake_mic_paused = False


def _pause_wake_mic(mic_stream) -> bool:
    """STT용 마이크 전환 — wake 감시 스레드가 읽지 않게."""
    global _wake_mic_paused
    try:
        if not mic_stream.is_stopped():
            mic_stream.stop_stream()
        _wake_mic_paused = True
        return True
    except OSError:
        _wake_mic_paused = True
        return False


def _restart_wake_mic(mic_stream) -> bool:
    """호출어·답변 중 끊기 감시용 wake 마이크 재개."""
    global _wake_mic_paused
    try:
        if mic_stream.is_stopped():
            mic_stream.start_stream()
        _wake_mic_paused = False
        return True
    except OSError as e:
        print(f"[마이크] wake 스트림 복구 실패: {e}")
        ctx = _wake_interrupt_ctx
        audio = ctx.get("audio")
        if audio is None:
            _wake_mic_paused = True
            return False
        try:
            try:
                mic_stream.close()
            except OSError:
                pass
            new_stream, new_rate = open_mic_stream(audio)
            ctx["mic_stream"] = new_stream
            ctx["mic_rate"] = new_rate
            ctx["chunk"] = max(512, int(new_rate * 0.08))
            _wake_mic_paused = False
            print("[마이크] wake 스트림 재오픈")
            return True
        except OSError as e2:
            print(f"[마이크] wake 스트림 재오픈 실패: {e2}")
            _wake_mic_paused = True
            return False


def _stop_tts_playback() -> None:
    """TTS paplay/pygame 중지."""
    global _tts_play_proc
    with _tts_play_lock:
        proc = _tts_play_proc
        _tts_play_proc = None
    if proc is not None and proc.poll() is None:
        proc.terminate()
        try:
            proc.wait(timeout=0.5)
        except subprocess.TimeoutExpired:
            proc.kill()
    subprocess.run(
        ["pkill", "-x", "paplay"],
        check=False,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    try:
        if _mixer_ready:
            pygame.mixer.music.stop()
    except Exception:
        pass


def request_speech_interrupt() -> None:
    _speech_interrupt.set()
    _stop_tts_playback()
    _stop_media_playback()


def _begin_speech(interruptible: bool = True) -> None:
    global _speech_active, _speech_interruptible
    _speech_interrupt.clear()
    _speech_active = True
    _speech_interruptible = interruptible


def _end_speech() -> None:
    global _speech_active, _speech_interruptible
    _speech_active = False
    _speech_interruptible = False
    _speech_interrupt.clear()


def _play_audio_file(path: str) -> bool:
    """3.5mm 등 PULSE_SINK로 재생. 중단 시 False."""
    if _speech_interrupt.is_set():
        return False
    env = os.environ.copy()
    if PULSE_SINK:
        env["PULSE_SINK"] = PULSE_SINK
    paplay_cmd = [
        "paplay",
        f"--latency-msec={max(10, TTS_PLAY_LATENCY_MS)}",
        path,
    ]
    try:
        proc = subprocess.Popen(
            paplay_cmd,
            env=env,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
        with _tts_play_lock:
            global _tts_play_proc
            _tts_play_proc = proc
        while proc.poll() is None:
            if _speech_interrupt.is_set():
                proc.terminate()
                try:
                    proc.wait(timeout=0.5)
                except subprocess.TimeoutExpired:
                    proc.kill()
                return False
            time.sleep(0.05)
        with _tts_play_lock:
            if _tts_play_proc is proc:
                _tts_play_proc = None
        if proc.returncode != 0:
            raise subprocess.CalledProcessError(proc.returncode, paplay_cmd)
        return not _speech_interrupt.is_set()
    except (OSError, subprocess.CalledProcessError) as e:
        print(f"paplay 실패, pygame으로 대체: {e}")
    if _speech_interrupt.is_set():
        return False
    _ensure_mixer()
    pygame.mixer.music.set_volume(1.0)
    pygame.mixer.music.load(path)
    pygame.mixer.music.play()
    while pygame.mixer.music.get_busy():
        if _speech_interrupt.is_set():
            pygame.mixer.music.stop()
            pygame.mixer.music.unload()
            return False
        time.sleep(0.05)
    pygame.mixer.music.unload()
    return not _speech_interrupt.is_set()


def _ensure_mixer() -> None:
    """pygame mixer는 마이크/ONNX와 충돌할 수 있어 TTS 직전에만 초기화."""
    global _mixer_ready
    if _mixer_ready:
        return
    pygame.mixer.init()
    _mixer_ready = True


# ---------- 대화 로그 (질문/답변 분리 JSON) ----------
def _read_json_list(path: str) -> list:
    if not os.path.isfile(path):
        return []
    try:
        data = json.load(open(path, encoding="utf-8"))
        return data if isinstance(data, list) else []
    except (OSError, json.JSONDecodeError, TypeError):
        return []


def _append_json_list(path: str, item: dict) -> None:
    os.makedirs(os.path.dirname(path), exist_ok=True)
    items = _read_json_list(path)
    items.append(item)
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(items, f, ensure_ascii=False, indent=2)
        f.write("\n")
    os.replace(tmp, path)


def log_question(
    text: str,
    *,
    source: str = "voice",
    turn_id: str | None = None,
    stt_engine: str | None = None,
) -> str:
    """질문을 questions.json 에 추가. turn_id 반환."""
    tid = turn_id or uuid.uuid4().hex[:12]
    row = {
        "id": tid,
        "time": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
        "text": text,
        "source": source,
    }
    if stt_engine:
        row["stt_engine"] = stt_engine
    _append_json_list(QUESTIONS_LOG, row)
    return tid


def log_answer(
    text: str,
    *,
    turn_id: str,
    kind: str = "gemini",
    extra: dict | None = None,
    usage: dict | None = None,
) -> None:
    """답변을 answers.json 에 추가 (질문 id 와 연결)."""
    row = {
        "id": uuid.uuid4().hex[:12],
        "turn_id": turn_id,
        "time": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
        "text": text,
        "kind": kind,  # gemini | memory | device
    }
    if usage:
        row["usage"] = usage
    if extra:
        row["extra"] = extra
    _append_json_list(ANSWERS_LOG, row)
    if kind == "gemini" and usage:
        _increment_gemini_turn_count()


def _empty_usage_bucket() -> dict:
    return {
        "prompt_tokens": 0,
        "output_tokens": 0,
        "total_tokens": 0,
        "usd": 0.0,
        "krw": 0.0,
        "api_calls": 0,
    }


def _usage_from_genai_response(response) -> dict | None:
    usage = getattr(response, "usage_metadata", None)
    if not usage:
        return None
    pin = int(getattr(usage, "prompt_token_count", 0) or 0)
    pout = int(getattr(usage, "candidates_token_count", 0) or 0)
    total = int(getattr(usage, "total_token_count", 0) or (pin + pout))
    usd = estimate_cost_usd(pin, pout)
    krw = usd * USD_KRW
    return {
        "prompt_tokens": pin,
        "output_tokens": pout,
        "total_tokens": total,
        "usd": round(usd, 6),
        "krw": round(krw, 2),
    }


def _merge_usage_bucket(dest: dict, src: dict) -> None:
    dest["prompt_tokens"] = int(dest.get("prompt_tokens", 0)) + int(src.get("prompt_tokens", 0))
    dest["output_tokens"] = int(dest.get("output_tokens", 0)) + int(src.get("output_tokens", 0))
    dest["total_tokens"] = int(dest.get("total_tokens", 0)) + int(src.get("total_tokens", 0))
    dest["usd"] = round(float(dest.get("usd", 0)) + float(src.get("usd", 0)), 6)
    dest["krw"] = round(float(dest.get("krw", 0)) + float(src.get("krw", 0)), 2)
    dest["api_calls"] = int(dest.get("api_calls", 0)) + int(src.get("api_calls", 1))


class _TurnUsage:
    """한 턴(질문 1개) 안의 API 호출 usage 합산 — answers.json 1행에 저장."""

    def __init__(self) -> None:
        self._calls: list[dict] = []

    def add(self, usage: dict | None, *, label: str = "") -> None:
        if not usage:
            return
        row = dict(usage)
        if label:
            row["label"] = label
        self._calls.append(row)

    def merged(self) -> dict | None:
        if not self._calls:
            return None
        out = _empty_usage_bucket()
        for call in self._calls:
            _merge_usage_bucket(out, call)
        out["api_calls"] = len(self._calls)
        if len(self._calls) > 1:
            out["calls"] = self._calls
        return out


def load_usage_summary() -> dict:
    if not os.path.isfile(USAGE_SUMMARY_PATH):
        return {
            "updated": None,
            "totals": _empty_usage_bucket(),
            "totals_extra": {"gemini_turns": 0},
            "today": None,
        }
    try:
        data = json.load(open(USAGE_SUMMARY_PATH, encoding="utf-8"))
        if not isinstance(data, dict):
            raise TypeError("usage summary not dict")
    except (OSError, json.JSONDecodeError, TypeError):
        return {
            "updated": None,
            "totals": _empty_usage_bucket(),
            "totals_extra": {"gemini_turns": 0},
            "today": None,
        }
    data.setdefault("totals", _empty_usage_bucket())
    data.setdefault("totals_extra", {"gemini_turns": 0})
    return data


def save_usage_summary(summary: dict) -> None:
    summary["updated"] = datetime.now(timezone.utc).astimezone().isoformat(
        timespec="seconds"
    )
    os.makedirs(os.path.dirname(USAGE_SUMMARY_PATH), exist_ok=True)
    tmp = USAGE_SUMMARY_PATH + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2)
        f.write("\n")
    os.replace(tmp, USAGE_SUMMARY_PATH)


def _today_bucket(summary: dict) -> dict:
    today_str = datetime.now(timezone.utc).astimezone().date().isoformat()
    today = summary.get("today")
    if not isinstance(today, dict) or today.get("date") != today_str:
        today = {"date": today_str, **_empty_usage_bucket()}
        today["gemini_turns"] = 0
        summary["today"] = today
    return today


def record_api_usage(usage: dict | None) -> dict:
    """API 1회 usage를 summary에 반영하고 전체 totals 반환."""
    if not usage:
        return load_usage_summary()["totals"]
    summary = load_usage_summary()
    u = dict(usage)
    u.setdefault("api_calls", 1)
    _merge_usage_bucket(summary["totals"], u)
    _merge_usage_bucket(_today_bucket(summary), u)
    save_usage_summary(summary)
    return summary["totals"]


def _increment_gemini_turn_count() -> None:
    summary = load_usage_summary()
    extra = summary.setdefault("totals_extra", {"gemini_turns": 0})
    extra["gemini_turns"] = int(extra.get("gemini_turns", 0)) + 1
    today = _today_bucket(summary)
    today["gemini_turns"] = int(today.get("gemini_turns", 0)) + 1
    save_usage_summary(summary)


def rebuild_usage_summary_from_logs() -> dict:
    """answers.json 의 usage 필드에서 summary 재계산 (마이그레이션·복구용)."""
    totals = _empty_usage_bucket()
    totals_extra = {"gemini_turns": 0}
    today_str = datetime.now(timezone.utc).astimezone().date().isoformat()
    today = {"date": today_str, **_empty_usage_bucket(), "gemini_turns": 0}
    for row in _read_json_list(ANSWERS_LOG):
        if row.get("kind") != "gemini":
            continue
        totals_extra["gemini_turns"] += 1
        if str(row.get("time", "")).startswith(today_str):
            today["gemini_turns"] += 1
        usage = row.get("usage")
        if not isinstance(usage, dict):
            continue
        _merge_usage_bucket(totals, usage)
        if str(row.get("time", "")).startswith(today_str):
            _merge_usage_bucket(today, usage)
    summary = {
        "updated": None,
        "totals": totals,
        "totals_extra": totals_extra,
        "today": today,
    }
    save_usage_summary(summary)
    return summary


def format_usage_totals_line(totals: dict, *, prefix: str = "누적") -> str:
    return (
        f"{prefix}: 토큰 {int(totals.get('total_tokens', 0))} "
        f"(입력 {int(totals.get('prompt_tokens', 0))} / 출력 {int(totals.get('output_tokens', 0))}) "
        f"| ≈ ${float(totals.get('usd', 0)):.4f} (약 {float(totals.get('krw', 0)):.2f}원) "
        f"| API {int(totals.get('api_calls', 0))}회"
    )


def print_usage_summary_brief(summary: dict | None = None) -> None:
    summary = summary or load_usage_summary()
    totals = summary.get("totals", _empty_usage_bucket())
    extra = summary.get("totals_extra", {})
    turns = int(extra.get("gemini_turns", 0))
    print(format_usage_totals_line(totals, prefix="API 전체"))
    today = summary.get("today")
    if isinstance(today, dict) and today.get("total_tokens", 0) > 0:
        print(
            format_usage_totals_line(today, prefix=f"오늘({today.get('date', '?')})")
            + f" | 대화 {int(today.get('gemini_turns', 0))}턴"
        )
    if turns:
        print(f"Gemini 대화 누적: {turns}턴")


# ---------- 영구 메모리 ----------
def load_memories() -> list[str]:
    if not os.path.isfile(MEMORY_PATH):
        return []
    try:
        data = json.load(open(MEMORY_PATH, encoding="utf-8"))
        items = data if isinstance(data, list) else data.get("items", [])
        return [str(x).strip() for x in items if str(x).strip()]
    except (OSError, json.JSONDecodeError, TypeError):
        return []


def save_memories(items: list[str]) -> None:
    items = items[-MEMORY_MAX:]
    with open(MEMORY_PATH, "w", encoding="utf-8") as f:
        json.dump(items, f, ensure_ascii=False, indent=2)


def build_system_instruction(memories: list[str] | None = None) -> str:
    memories = load_memories() if memories is None else memories
    if not memories:
        return BASE_INSTRUCTION
    lines = "\n".join(f"- {m}" for m in memories)
    return f"{BASE_INSTRUCTION}\n\n[영구 기억]\n{lines}"


def load_session_history_from_logs(max_pairs: int) -> list[tuple[str, str]]:
    """최근 질문·답변 로그에서 대화 맥락 복원."""
    if max_pairs <= 0:
        return []
    questions = _read_json_list(QUESTIONS_LOG)
    answers = _read_json_list(ANSWERS_LOG)
    by_turn: dict[str, str] = {}
    for row in answers:
        if row.get("kind") != "gemini":
            continue
        tid = row.get("turn_id")
        text = str(row.get("text", "")).strip()
        if tid and text:
            by_turn[tid] = text
    pairs: list[tuple[str, str]] = []
    for q in questions:
        tid = q.get("id")
        qtext = str(q.get("text", "")).strip()
        if tid and qtext and tid in by_turn:
            pairs.append((qtext, by_turn[tid]))
    return pairs[-max_pairs:]


def append_session_turn(user_text: str, answer: str) -> None:
    global _session_history
    _session_history.append((user_text.strip(), answer.strip()))
    if len(_session_history) > CHAT_HISTORY_MAX:
        _session_history = _session_history[-CHAT_HISTORY_MAX:]


def rebuild_chat(*, load_logs: bool = False) -> None:
    """메모리 반영된 새 모델/채팅 세션. load_logs=True면 로그에서 맥락 복원."""
    global model, chat, _session_history
    model = genai.GenerativeModel(
        GEMINI_MODEL,
        system_instruction=build_system_instruction(),
    )
    chat = model.start_chat(history=[])
    if load_logs:
        _session_history = load_session_history_from_logs(CHAT_HISTORY_MAX)
    elif not _session_history:
        _session_history = []


def _compact(text: str) -> str:
    return re.sub(r"\s+", "", text.strip())


def handle_memory_command(user_text: str) -> str | None:
    """기억 관련 로컬 명령이면 응답 문자열, 아니면 None (제미나이로 전달)."""
    t = user_text.strip()
    c = _compact(t)

    # 목록
    list_keys = (
        "기억뭐있",
        "기억목록",
        "뭐기억",
        "기억보여",
        "기억확인",
        "기억내용",
        "기억읽어",
    )
    if any(k in c for k in list_keys):
        items = load_memories()
        if not items:
            return "저장된 기억이 없습니다."
        body = ", ".join(f"{i + 1}번 {m}" for i, m in enumerate(items))
        return f"기억 {len(items)}개 있습니다. {body}"

    # 전체 삭제
    clear_keys = (
        "기억다지워",
        "기억전부삭제",
        "기억초기화",
        "잊어전부",
        "기억모두지워",
        "기억다삭제",
    )
    if any(k in c for k in clear_keys):
        save_memories([])
        rebuild_chat()
        return "기억을 모두 지웠습니다."

    # 기억해 …
    for prefix in ("기억해줘", "기억해줄래", "기억해", "기억할것"):
        if c.startswith(_compact(prefix)) or t.startswith(prefix):
            content = t
            for p in ("기억해줘", "기억해줄래", "기억해", "기억할 것", "기억할것"):
                if content.startswith(p):
                    content = content[len(p) :].lstrip(" .,'\"")
                    break
            else:
                # compact 매칭만 된 경우
                content = re.sub(
                    r"^\s*기억해(줘|줄래)?\s*", "", t
                ).strip(" .,'\"")
            if not content:
                return "무엇을 기억할까요? 예를 들어, 기억해 반말로 짧게 답해."
            items = load_memories()
            if content in items:
                return "이미 같은 내용이 기억되어 있습니다."
            items.append(content)
            save_memories(items)
            rebuild_chat()
            return f"기억했습니다. {content}"

    # 잊어 … / 지워 …
    for prefix in ("잊어줘", "잊어", "지워줘", "삭제해줘", "삭제해"):
        if c.startswith(_compact(prefix)) or t.startswith(prefix):
            keyword = t
            for p in ("잊어줘", "잊어", "지워줘", "삭제해줘", "삭제해"):
                if keyword.startswith(p):
                    keyword = keyword[len(p) :].lstrip(" .,'\"")
                    break
            if not keyword:
                return "무엇을 잊을까요? 예를 들어, 잊어 맵기."
            items = load_memories()
            kept = [m for m in items if keyword not in m]
            removed = len(items) - len(kept)
            if removed == 0:
                return f"'{keyword}'가 들어간 기억이 없습니다."
            save_memories(kept)
            rebuild_chat()
            return f"{removed}개 기억을 지웠습니다."

    return None


rebuild_chat()


# ---------- 화이트리스트 장치 명령 (셸 임의 실행 금지) ----------
def get_speaker_volume_percent() -> int | None:
    try:
        r = subprocess.run(
            ["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"],
            capture_output=True,
            text=True,
            timeout=5,
        )
        if r.returncode == 0:
            # "Volume: 0.80" or "Volume: 1.00 [MUTED]"
            m = re.search(r"Volume:\s*([0-9.]+)", r.stdout)
            if m:
                return int(round(float(m.group(1)) * 100))
    except (FileNotFoundError, subprocess.TimeoutExpired, ValueError):
        pass
    return None


def run_hook(name: str, *args: str) -> tuple[bool, str]:
    """jarvis_hooks/<name>.sh 만 실행 (화이트리스트). 추가 인자는 스크립트로 전달."""
    path = os.path.join(HOOKS_DIR, f"{name}.sh")
    if not os.path.isfile(path):
        return False, f"{name} 스크립트가 없습니다."
    try:
        r = subprocess.run(
            ["/bin/bash", path, *args],
            capture_output=True,
            text=True,
            timeout=15,
        )
        if r.returncode == 0:
            return True, (r.stdout or "").strip() or "완료"
        return False, (r.stderr or r.stdout or "실패").strip()
    except subprocess.TimeoutExpired:
        return False, "시간 초과"
    except OSError as e:
        return False, str(e)


def _ha_api():
    """jarvis_hooks/ha_api.py 로드."""
    import importlib.util

    path = os.path.join(HOOKS_DIR, "ha_api.py")
    spec = importlib.util.spec_from_file_location("jarvis_ha_api", path)
    if spec is None or spec.loader is None:
        raise RuntimeError("ha_api.py 로드 실패")
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


_ha_tool_calls: list[str] = []


def _reset_ha_tool_calls() -> None:
    _ha_tool_calls.clear()


def get_home_snapshot() -> str:
    """지금 집안에서 자비스가 조작 가능한(enabled) 기기 목록과 상태를 조회한다."""
    _ha_tool_calls.append("get_home_snapshot")
    print("[HA tool] get_home_snapshot")
    return _ha_api().tool_get_home_snapshot()


def control_light(name: str, action: str) -> str:
    """조명을 켠다/끈다. name은 방/조명 이름(예: 거실, 메인등, 아기방). action은 on 또는 off."""
    _ha_tool_calls.append("control_light")
    print(f"[HA tool] control_light name={name!r} action={action!r}")
    return _ha_api().tool_control_light(name, action)


def control_climate(
    name: str = "", action: str = "turn_on", temperature: int = 24
) -> str:
    """에어컨을 켠다/끈다/온도를 맞춘다. action: turn_on, turn_off, set. temperature 기본 24."""
    _ha_tool_calls.append("control_climate")
    print(
        f"[HA tool] control_climate name={name!r} action={action!r} temp={temperature}"
    )
    return _ha_api().tool_control_climate(name, action, temperature)


def control_tv(name: str = "", action: str = "turn_on") -> str:
    """티비를 켠다/끈다. name 생략 시 거실 티비. action: on 또는 off."""
    _ha_tool_calls.append("control_tv")
    print(f"[HA tool] control_tv name={name!r} action={action!r}")
    return _ha_api().tool_control_tv(name, action)


def get_washer_status() -> str:
    """세탁기 남은 시간·전원·완료 여부를 조회한다."""
    _ha_tool_calls.append("get_washer_status")
    print("[HA tool] get_washer_status")
    return _ha_api().tool_get_washer_status()


HA_GEMINI_TOOLS = [
    get_home_snapshot,
    control_light,
    control_climate,
    control_tv,
    get_washer_status,
]


def ensure_ha_entities_file() -> None:
    """엔티티 JSON 없으면 HA에서 동기화."""
    path = os.path.join(BASE_DIR, "jarvis_ha_entities.json")
    if os.path.isfile(path):
        return
    try:
        _ha_api().sync_entities_file()
    except Exception as e:
        print(f"[ha_entities] 최초 sync 실패: {e}")


MEDIA_PID_PATH = os.path.join(LOG_DIR, "media.pid")


def _is_media_playing() -> bool:
    """유튜브/VLC 재생 중인지."""
    with _media_lock:
        if _media_proc is not None and _media_proc.poll() is None:
            return True
    if os.path.isfile(MEDIA_PID_PATH):
        try:
            with open(MEDIA_PID_PATH, encoding="utf-8") as f:
                pid = int(f.read().strip())
            os.kill(pid, 0)
            return True
        except (OSError, ValueError):
            pass
    return False


def _wake_hit_thresholds(media_on: bool) -> tuple[float, float]:
    """재생 중엔 호출어 임계를 조금 낮춤 (스피커 소리 속에서도 듣기)."""
    if media_on:
        return (
            max(0.22, WAKE_THRESHOLD - 0.06),
            max(0.38, WAKE_STRONG_SCORE - 0.08),
        )
    return WAKE_THRESHOLD, WAKE_STRONG_SCORE


def _stop_media_playback() -> None:
    """유튜브/VLC 재생 중지."""
    global _media_proc, _youtube_pending
    with _media_lock:
        _youtube_pending = None
        if os.path.isfile(MEDIA_PID_PATH):
            try:
                with open(MEDIA_PID_PATH, encoding="utf-8") as f:
                    pid = int(f.read().strip())
                os.kill(pid, 15)
                time.sleep(0.2)
                os.kill(pid, 9)
            except (OSError, ValueError):
                pass
            try:
                os.remove(MEDIA_PID_PATH)
            except OSError:
                pass
        if _media_proc is not None and _media_proc.poll() is None:
            _media_proc.terminate()
            try:
                _media_proc.wait(timeout=3)
            except subprocess.TimeoutExpired:
                _media_proc.kill()
        _media_proc = None
        subprocess.run(
            ["pkill", "-x", "cvlc"],
            check=False,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )


_YT_PLAY_STRIP_PATS = (
    r"유튜브에서?",
    r"유튜브로",
    r"youtube에서?",
    r"youtube로",
    r"노래",
    r"음악",
    r"뮤직",
    r"틀어달라",
    r"틀어\s*달라",
    r"틀어\s*줘?",
    r"틀어\s*봐",
    r"재생해?\s*줘?",
    r"들려\s*줘?",
    r"플레이",
    r"해\s*줘",
    r"줘",
    r"봐",
    r"달라",
    r"이라는",
    r"라는",
)


def _normalize_youtube_query(query: str) -> str:
    """검색용 — 곡 제목 바꾸지 않고 STT 흔한 오인식만 (출출해↔추출해)."""
    q = re.sub(r"\s+", " ", query.strip())
    if re.search(r"매[점장]", q) and "추출" in q:
        q = re.sub(r"추출", "출출", q)
    if re.search(r"매[점장]", q) and "수출" in q:
        q = re.sub(r"수출", "출출", q)
    return q.strip()


def _strip_youtube_play_words(text: str) -> str:
    q = text
    for pat in _YT_PLAY_STRIP_PATS:
        q = re.sub(pat, "", q, flags=re.IGNORECASE)
    return re.sub(r"\s+", " ", q).strip(" .,'\"!?")


def _extract_youtube_query(text: str) -> str | None:
    """「유튜브에서 OOO 틀어줘」 / 「OOO 틀어줘」 → OOO. 빈 제목은 ''."""
    t = text.strip()
    c = _compact(t)
    play_words = ("틀어", "재생", "들려", "플레이", "play", "틀어줘", "틀어줄래")
    media_words = (
        "유튜브",
        "youtube",
        "노래",
        "음악",
        "뮤직",
        "song",
        "music",
    )
    has_play = any(w in c for w in play_words)
    has_media = any(k in c for k in media_words)
    if not has_play and not has_media:
        return None

    q = _strip_youtube_play_words(t)
    if len(q) >= 2:
        return _normalize_youtube_query(q)
    if has_play or has_media:
        return ""
    return None


def _youtube_music_search_query(query: str) -> str:
    """API/yt-dlp 검색어 — 노래 의도면 '노래' 보강 (쇼핑·리뷰 영상 방지)."""
    q = re.sub(r"\s+", " ", query.strip())
    c = _compact(q)
    if not any(
        k in c
        for k in (
            "노래",
            "음악",
            "뮤직",
            "mv",
            "official",
            "뮤직비디오",
            "music",
            "song",
            "가사",
        )
    ):
        return f"{q} 노래"
    return q


def _youtube_api_search(query: str) -> tuple[str, str] | None:
    """YouTube Data API v3 검색 → (watch_url, 제목). 키 없거나 실패 시 None."""
    key = YOUTUBE_API_KEY
    if not key or "여기에_" in key:
        return None
    try:
        from googleapiclient.discovery import build

        youtube = build("youtube", "v3", developerKey=key, cache_discovery=False)
        search_q = _youtube_music_search_query(query)
        res = (
            youtube.search()
            .list(
                part="id,snippet",
                q=search_q,
                type="video",
                maxResults=1,
                videoCategoryId="10",
                safeSearch="none",
            )
            .execute()
        )
        items = res.get("items") or []
        if not items:
            res = (
                youtube.search()
                .list(
                    part="id,snippet",
                    q=search_q,
                    type="video",
                    maxResults=1,
                    safeSearch="none",
                )
                .execute()
            )
        items = res.get("items") or []
        if not items:
            print(f"[유튜브 API] 결과 없음: {query}")
            return None
        item = items[0]
        vid = item.get("id", {}).get("videoId")
        title = html.unescape(str(item.get("snippet", {}).get("title", "")).strip())
        if not vid:
            return None
        url = f"https://www.youtube.com/watch?v={vid}"
        print(f"[유튜브 API] {title} ({vid})")
        return url, title or query
    except Exception as e:
        err = str(e)
        if "403" in err and "blocked" in err.lower():
            print(
                "[유튜브 API] 사용 차단 — Google Cloud에서 "
                "YouTube Data API v3 활성화·키 제한 확인 필요. yt-dlp로 대체."
            )
        else:
            print(f"[유튜브 API] 검색 실패: {e}")
        return None


def _resolve_youtube_watch_url(query: str) -> tuple[str | None, str, str]:
    """검색어 → (재생 URL, 표시 제목, api|yt-dlp|none)."""
    mode = YOUTUBE_SEARCH_MODE
    if mode not in ("api", "yt-dlp", "auto"):
        mode = "auto"

    if mode in ("api", "auto"):
        api_hit = _youtube_api_search(query)
        if api_hit:
            return api_hit[0], api_hit[1], "api"

    if mode == "api":
        return None, query, "none"

    if not os.path.isfile(YT_DLP_BIN):
        return None, query, "none"
    search_q = _youtube_music_search_query(query)
    try:
        res = subprocess.run(
            [
                YT_DLP_BIN,
                "-f",
                "bestaudio/best",
                "--no-playlist",
                "-g",
                f"ytsearch1:{search_q}",
            ],
            capture_output=True,
            text=True,
            timeout=90,
            check=False,
        )
        url = (res.stdout or "").strip().split("\n")[0].strip()
        if url.startswith("http"):
            print(f"[유튜브 yt-dlp] {query}")
            return url, query, "yt-dlp"
    except (OSError, subprocess.TimeoutExpired) as e:
        print(f"[유튜브 yt-dlp] 검색 실패: {e}")
    return None, query, "none"


def _start_youtube_play(query: str) -> str:
    """검색만 하고 재생은 짧은 TTS 후 (_commit_youtube_play)."""
    global _youtube_pending
    if not os.path.isfile(YT_DLP_BIN):
        print(f"[유튜브] yt-dlp 없음: {YT_DLP_BIN}")
        return "유튜브 검색 프로그램이 없어요."
    watch_url, title, src = _resolve_youtube_watch_url(query)
    if not watch_url:
        _youtube_pending = None
        return f"'{query}' 검색이 안 됐어요. 제목을 다시 말해 주세요."
    _youtube_pending = (query, watch_url)
    print(f"[유튜브] 예약: {title} ({src})")
    say_q = query if len(query) <= 36 else query[:33] + "..."
    return f"네, '{say_q}' 틀어줄게요."


def _commit_youtube_play() -> None:
    """예약된 유튜브 재생 시작 (TTS 확인 후)."""
    global _media_proc, _youtube_pending
    if not _youtube_pending:
        return
    query, watch_url = _youtube_pending
    _youtube_pending = None
    script = os.path.join(BASE_DIR, "jarvis_yt_play.sh")
    if not os.path.isfile(script):
        print(f"[유튜브] 스크립트 없음: {script}")
        return
    env = os.environ.copy()
    sink = os.environ.get("PULSE_SINK", "").strip() or PULSE_SINK
    if sink:
        env["PULSE_SINK"] = sink
    with _media_lock:
        _stop_media_playback()
        cmd = ["/bin/bash", script, query]
        if watch_url:
            cmd.append(watch_url)
        _media_proc = subprocess.Popen(
            cmd,
            env=env,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            start_new_session=True,
        )
    print(f"[유튜브] 재생 시작: {query} (pid={_media_proc.pid})")


def handle_device_command(user_text: str) -> str | None:
    """볼륨/조명/가전 등 허용된 로컬 명령. 해당 없으면 None."""
    c = _compact(user_text)

    # --- 유튜브 / 음악 재생 ---
    if any(
        k in c
        for k in (
            "노래꺼",
            "음악꺼",
            "노래멈춰",
            "음악멈춰",
            "재생멈춰",
            "재생꺼",
            "플레이멈춰",
            "노래끄",
            "음악끄",
        )
    ):
        _stop_media_playback()
        return "재생을 멈췄습니다."

    yt_q = _extract_youtube_query(user_text)
    if yt_q is not None:
        if not yt_q.strip():
            return "어떤 노래를 틀어줄까요? 제목을 말해 주세요."
        return _start_youtube_play(yt_q)

    # --- 세탁기 (HA 실시간) ---
    if any(
        k in c
        for k in (
            "세탁기얼마",
            "세탁얼마",
            "세탁남은",
            "세탁기남은",
            "세탁기상태",
            "세탁상태",
            "세탁완료됐",
        )
    ) or (
        "세탁" in c
        and any(k in c for k in ("얼마", "남", "몇분", "상태", "돌아가", "돌고"))
    ):
        try:
            return _ha_api().washer_status()
        except Exception as e:
            return f"세탁기 상태를 못 읽었습니다. {e}"

    # --- 티비 (media_player, 단답) ---
    has_tv = any(k in c for k in ("티비", "티브이", "텔레비전")) or "tv" in c.lower()
    if has_tv and any(k in c for k in ("켜", "꺼", "온", "오프")):
        turn_on = not any(k in c for k in ("꺼", "오프", "off"))
        try:
            ha = _ha_api()
            hit = ha.resolve_tv(user_text)
            if not hit:
                return "티비를 HA에서 못 찾았어요. jarvis_ha_entities.json 의 media_player enabled를 확인하세요."
            fn, eid = hit
            ha.tv_service(eid, turn_on)
            return f"{fn}을 켰습니다." if turn_on else f"{fn}을 껐습니다."
        except Exception as e:
            return f"티비 조작 실패. {e}"

    # --- 불 (HA light.* 실시간 검색 — json 일일이 안 넣어도 됨) ---
    is_on = any(
        k in c
        for k in ("불켜", "라이트온", "조명켜", "등켜", "불켜줘", "불켜봐")
    ) or ("켜" in c and any(x in c for x in ("방", "등", "라이트", "조명", "메인")))
    is_off = any(
        k in c
        for k in ("불꺼", "라이트오프", "조명꺼", "등꺼", "불꺼줘")
    ) or ("꺼" in c and any(x in c for x in ("방", "등", "라이트", "조명", "메인")))

    if is_on or is_off:
        try:
            ha = _ha_api()
            hit = ha.resolve_light(user_text)
            if not hit:
                return (
                    "어떤 불인지 HA에서 못 찾았어요. "
                    "웹에 보이는 조명 이름으로 말해 주세요."
                )
            fn, eid = hit
            ha.light_service(eid, is_on)
            return f"{fn}을 켰습니다." if is_on else f"{fn}을 껐습니다."
        except Exception as e:
            return f"조명 조작 실패. {e}"

    # --- 음소거 ---
    if any(k in c for k in ("음소거", "소리꺼", "뮤트")) and "해제" not in c:
        for cmd in (
            ["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "1"],
            ["pactl", "set-sink-mute", "@DEFAULT_SINK@", "1"],
        ):
            subprocess.run(cmd, capture_output=True, timeout=5)
        return "음소거 했습니다."
    if any(k in c for k in ("음소거해제", "소리켜", "뮤트해제")):
        for cmd in (
            ["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "0"],
            ["pactl", "set-sink-mute", "@DEFAULT_SINK@", "0"],
        ):
            subprocess.run(cmd, capture_output=True, timeout=5)
        return "음소거를 해제했습니다."

    # --- 볼륨 절대값: "볼륨 50", "볼륨50퍼" ---
    m = re.search(r"볼륨\s*(\d{1,3})", user_text)
    if not m:
        m = re.search(r"소리\s*(\d{1,3})\s*%?", user_text)
    if m and not any(k in c for k in ("올려", "높여", "키워", "내려", "줄여", "낮춰")):
        pct = max(0, min(150, int(m.group(1))))
        set_speaker_volume(pct)
        return f"볼륨을 {pct}%로 맞췄습니다."

    if any(k in c for k in ("볼륨최대", "소리최대", "볼륨최대로")):
        set_speaker_volume(100)
        return "볼륨을 최대로 올렸습니다."

    # --- 볼륨 상대 ---
    cur = get_speaker_volume_percent()
    if any(k in c for k in ("볼륨올려", "소리키워", "소리크게", "볼륨크게", "볼륨높여")):
        nxt = min(150, (cur if cur is not None else 70) + 15)
        set_speaker_volume(nxt)
        return f"볼륨을 {nxt}%로 올렸습니다."
    if any(k in c for k in ("볼륨내려", "소리줄여", "소리작게", "볼륨작게", "볼륨낮춰")):
        nxt = max(0, (cur if cur is not None else 70) - 15)
        set_speaker_volume(nxt)
        return f"볼륨을 {nxt}%로 내렸습니다."

    if any(k in c for k in ("볼륨몇", "지금볼륨", "볼륨얼마", "소리크기")):
        if cur is None:
            return "지금 볼륨을 읽지 못했습니다."
        return f"지금 볼륨은 {cur}%입니다."

    return None


# ---------- 오디오 / Gemini 유틸 ----------
def set_speaker_volume(percent: int) -> None:
    percent = max(0, min(150, int(percent)))
    for cmd in (
        ["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", f"{percent / 100:.2f}"],
        ["pactl", "set-sink-volume", "@DEFAULT_SINK@", f"{percent}%"],
        ["amixer", "-q", "sset", "Master", f"{min(percent, 100)}%"],
        ["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "0"],
    ):
        try:
            subprocess.run(cmd, capture_output=True, timeout=5)
        except (FileNotFoundError, subprocess.TimeoutExpired):
            pass


def estimate_cost_usd(prompt_tokens: int, output_tokens: int) -> float:
    return (
        prompt_tokens / 1_000_000 * PRICE_INPUT_PER_M
        + output_tokens / 1_000_000 * PRICE_OUTPUT_PER_M
    )


def print_usage(response, label: str = "") -> None:
    usage = getattr(response, "usage_metadata", None)
    if not usage:
        print("토큰 정보 없음")
        return
    pin = int(getattr(usage, "prompt_token_count", 0) or 0)
    pout = int(getattr(usage, "candidates_token_count", 0) or 0)
    total = int(getattr(usage, "total_token_count", 0) or (pin + pout))
    billed_out = max(pout, total - pin)
    usd = estimate_cost_usd(pin, billed_out)
    krw = usd * USD_KRW
    tag = f"[{label}] " if label else ""
    print(
        f"{tag}토큰: 입력={pin} 출력(후보)={pout} 합계={total} "
        f"| 유료환산 ≈ ${usd:.6f} (약 {krw:.2f}원) / Free면 0원"
    )


def _get_gemini_text_client() -> genai_client.Client:
    global _gemini_text_client
    if _gemini_text_client is None:
        _gemini_text_client = genai_client.Client(api_key=GOOGLE_API_KEY)
    return _gemini_text_client


def print_usage_genai(response, label: str = "") -> dict | None:
    usage = _usage_from_genai_response(response)
    if not usage:
        return None
    tag = f"[{label}] " if label else ""
    print(
        f"{tag}토큰: 입력={usage['prompt_tokens']} 출력={usage['output_tokens']} "
        f"합계={usage['total_tokens']} "
        f"| 유료환산 ≈ ${usage['usd']:.6f} (약 {usage['krw']:.2f}원) / Free면 0원"
    )
    totals = record_api_usage(usage)
    print(f"  ↳ {format_usage_totals_line(totals)}")
    return usage


def _build_gemini_contents(user_text: str, use_session_history: bool = True) -> list | str:
    if use_session_history and _session_history:
        contents: list = []
        for u, a in _session_history:
            contents.append(
                genai_types.Content(
                    role="user", parts=[genai_types.Part(text=u)]
                )
            )
            contents.append(
                genai_types.Content(
                    role="model", parts=[genai_types.Part(text=a)]
                )
            )
        contents.append(
            genai_types.Content(
                role="user", parts=[genai_types.Part(text=user_text)]
            )
        )
        return contents
    return user_text


def _gemini_generate_config(
    *,
    max_tokens: int,
    system: str | None = None,
    with_ha_tools: bool = False,
) -> genai_types.GenerateContentConfig:
    kwargs: dict = {
        "system_instruction": system or build_system_instruction(),
        "max_output_tokens": max_tokens,
        "thinking_config": genai_types.ThinkingConfig(thinking_budget=0),
    }
    if with_ha_tools:
        kwargs["tools"] = HA_GEMINI_TOOLS
        kwargs["automatic_function_calling"] = (
            genai_types.AutomaticFunctionCallingConfig(maximum_remote_calls=3)
        )
    return genai_types.GenerateContentConfig(**kwargs)


def _genai_generate_text(
    user_text: str,
    *,
    max_tokens: int,
    system: str | None = None,
    use_session_history: bool = True,
    with_ha_tools: bool = False,
):
    """thinking 비활성 — 답 중간 끊김 방지. use_session_history로 직전 대화 맥락 전달."""
    client = _get_gemini_text_client()
    cfg = _gemini_generate_config(
        max_tokens=max_tokens, system=system, with_ha_tools=with_ha_tools
    )
    contents = _build_gemini_contents(user_text, use_session_history)
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        with open(os.devnull, "w") as devnull:
            with contextlib.redirect_stderr(devnull):
                return client.models.generate_content(
                    model=GEMINI_MODEL,
                    contents=contents,
                    config=cfg,
                )


def _genai_generate_text_stream(
    user_text: str,
    *,
    max_tokens: int,
    system: str | None = None,
    use_session_history: bool = True,
):
    """스트리밍 텍스트 생성 — 첫 문장 TTS를 빨리 시작."""
    client = _get_gemini_text_client()
    cfg = _gemini_generate_config(max_tokens=max_tokens, system=system)
    contents = _build_gemini_contents(user_text, use_session_history)
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        with open(os.devnull, "w") as devnull:
            with contextlib.redirect_stderr(devnull):
                return client.models.generate_content_stream(
                    model=GEMINI_MODEL,
                    contents=contents,
                    config=cfg,
                )


def _get_gemini_tts_client() -> genai_client.Client:
    global _gemini_tts_client
    if _gemini_tts_client is None:
        _gemini_tts_client = genai_client.Client(api_key=GOOGLE_API_KEY)
    return _gemini_tts_client


def _synthesize_gemini(text: str, path: str) -> None:
    client = _get_gemini_tts_client()
    prefix = TTS_STYLE.strip()
    contents = f"{prefix} {text}" if prefix and not text.startswith(prefix) else text
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        with open(os.devnull, "w") as devnull:
            with contextlib.redirect_stderr(devnull):
                response = client.models.generate_content(
                    model=TTS_MODEL,
                    contents=contents,
                    config=genai_types.GenerateContentConfig(
                        response_modalities=["AUDIO"],
                        speech_config=genai_types.SpeechConfig(
                            voice_config=genai_types.VoiceConfig(
                                prebuilt_voice_config=genai_types.PrebuiltVoiceConfig(
                                    voice_name=TTS_VOICE
                                )
                            )
                        ),
                    ),
                )
    part = response.candidates[0].content.parts[0]
    pcm = part.inline_data.data
    with wave.open(path, "wb") as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)
        wf.setframerate(TTS_SAMPLE_RATE)
        wf.writeframes(pcm)


async def _synthesize_edge(text: str, path: str) -> None:
    voice = TTS_VOICE
    if voice in ("Kore", "Leda", "Zephyr", "Puck", "Charon"):
        voice = DEFAULT_EDGE_TTS_VOICE
    communicate = edge_tts.Communicate(
        text, voice=voice, rate=TTS_RATE, pitch=TTS_PITCH
    )
    await communicate.save(path)


def synthesize_speech(text: str, base_path: str) -> str:
    """음성 합성. 실제 저장 경로 반환."""
    root, ext = os.path.splitext(base_path)
    if not ext:
        root = base_path
    min_pcm_bytes = max(8000, len(text) * 120)
    if TTS_BACKEND == "gemini":
        wav_path = f"{root}.wav"
        try:
            _synthesize_gemini(text, wav_path)
            if os.path.getsize(wav_path) >= min_pcm_bytes:
                return wav_path
            print(
                f"Gemini TTS 오디오 짧음 ({os.path.getsize(wav_path)}B), "
                "edge-tts로 대체"
            )
        except Exception as e:
            print(f"Gemini TTS 실패, edge-tts로 대체: {e}")
    mp3_path = f"{root}.mp3"
    asyncio.run(_synthesize_edge(text, mp3_path))
    return mp3_path


def play_beep(freq: float = 880.0, ms: int = 160, volume: float = 0.4) -> None:
    """입력 대기 진입 알림용 짧은 비프 (3.5mm/기본 싱크)."""
    try:
        rate = 24000
        n = max(1, int(rate * ms / 1000))
        t = np.arange(n, dtype=np.float32) / rate
        wave_arr = np.sin(2 * np.pi * freq * t)
        fade = np.linspace(1.0, 0.0, n, dtype=np.float32)
        mono = (wave_arr * fade * 32767 * volume).astype(np.int16)
        beep_path = os.path.join(BASE_DIR, "beep.wav")
        with wave.open(beep_path, "wb") as wf:
            wf.setnchannels(1)
            wf.setsampwidth(2)
            wf.setframerate(rate)
            wf.writeframes(mono.tobytes())
        _play_audio_file(beep_path)
    except Exception as e:
        print(f"비프 재생 실패: {e}")


def _clean_speech_text(text: str) -> str:
    clean = re.sub(r"[*_`#>•]+", " ", text)
    return re.sub(r"\s+", " ", clean).strip()


def _synth_chunk_path(base: str, save_as: str | None, index: int, spoken: str) -> str:
    chunk_base = base if save_as else f"{base}_{index}"
    return synthesize_speech(spoken, chunk_base)


def _remove_tts_file(path: str, save_as: str | None) -> None:
    if save_as is None:
        try:
            os.remove(path)
        except OSError:
            pass


class _TtsPrefetch:
    """답변 텍스트 → 첫 TTS 청크 합성을 백그라운드에서 선행."""

    def __init__(self, text: str, save_as: str | None = None) -> None:
        self.clean = _clean_speech_text(text)
        self.chunks = _chunks_for_speech(self.clean)
        self.base = save_as or os.path.join(BASE_DIR, "reply")
        self.save_as = save_as
        self._ready = threading.Event()
        self._first_spoken: str | None = None
        self._first_path: str | None = None
        self._error: BaseException | None = None
        if not self.chunks:
            self._ready.set()
            return
        _tts_executor.submit(self._run)

    def _run(self) -> None:
        try:
            spoken = self.chunks[0]
            path = _synth_chunk_path(self.base, self.save_as, 0, spoken)
            self._first_spoken = spoken
            self._first_path = path
        except Exception as e:
            self._error = e
        finally:
            self._ready.set()

    def wait_first(self, timeout: float = 90.0) -> bool:
        self._ready.wait(timeout)
        return self._first_path is not None and self._error is None


def warmup_tts() -> None:
    """TTS·API 미리 준비 — 첫 재생 지연 줄임."""
    try:
        _get_gemini_text_client()
        if TTS_BACKEND == "gemini":
            _get_gemini_tts_client()
            tmp = os.path.join(BASE_DIR, "tts_warmup.wav")
            _synthesize_gemini("음", tmp)
            if os.path.isfile(tmp):
                os.remove(tmp)
        else:
            tmp = os.path.join(BASE_DIR, "tts_warmup.mp3")
            asyncio.run(_synthesize_edge("테스트", tmp))
            if os.path.isfile(tmp):
                os.remove(tmp)
    except Exception:
        pass


def _chunks_for_speech(text: str) -> list[str]:
    """긴 답은 문장 단위로 나눠 TTS — 중간에 잘리지 않게."""
    t = text.strip()
    if not t:
        return []
    if TTS_CHUNK_CHARS <= 0 or len(t) <= TTS_CHUNK_CHARS:
        return [t]

    sentences = re.split(r"(?<=[.!?。])\s+", t)
    chunks: list[str] = []
    buf = ""
    for sentence in sentences:
        s = sentence.strip()
        if not s:
            continue
        if len(s) > TTS_CHUNK_CHARS:
            if buf:
                chunks.append(buf.strip())
                buf = ""
            for i in range(0, len(s), TTS_CHUNK_CHARS):
                part = s[i:i + TTS_CHUNK_CHARS].strip()
                if part:
                    chunks.append(part)
            continue
        candidate = f"{buf} {s}".strip() if buf else s
        if len(candidate) <= TTS_CHUNK_CHARS:
            buf = candidate
        else:
            if buf:
                chunks.append(buf)
            buf = s
    if buf:
        chunks.append(buf)
    return chunks or [t[:TTS_CHUNK_CHARS]]


def speak(
    text: str,
    save_as: str | None = None,
    prefetch: _TtsPrefetch | None = None,
    interruptible: bool = True,
) -> None:
    """TTS 합성 후 재생 — 로그는 재생 직전에 출력."""
    _begin_speech(interruptible=interruptible)
    try:
        if prefetch is None:
            prefetch = _TtsPrefetch(text, save_as)

        if not prefetch.wait_first():
            if prefetch._error:
                print(f"TTS 오류: {prefetch._error}")
            return

        clean = prefetch.clean
        chunks = prefetch.chunks
        base = prefetch.base
        save_as = prefetch.save_as

        if len(chunks) == 1:
            print(f"자비스: {prefetch._first_spoken}")
        else:
            print(f"자비스(전체): {clean}")

        path_future: Future | None = None
        if len(chunks) > 1:
            path_future = _tts_executor.submit(
                _synth_chunk_path, base, save_as, 1, chunks[1]
            )

        if not _play_audio_file(prefetch._first_path):
            return
        _remove_tts_file(prefetch._first_path, save_as)

        for i in range(1, len(chunks)):
            if _speech_interrupt.is_set():
                break
            spoken = chunks[i]
            if path_future is not None:
                path = path_future.result()
                path_future = None
            else:
                path = _synth_chunk_path(base, save_as, i, spoken)

            print(f"자비스 [{i + 1}/{len(chunks)}]: {spoken}")
            if i + 1 < len(chunks):
                path_future = _tts_executor.submit(
                    _synth_chunk_path, base, save_as, i + 1, chunks[i + 1]
                )
            if not _play_audio_file(path):
                break
            _remove_tts_file(path, save_as)
    finally:
        _end_speech()


def open_mic_stream(audio: pyaudio.PyAudio):
    """가능하면 Pulse(공유) 우선 — USB hw 독점은 디버그/녹음과 충돌."""
    candidates = []
    if MIC_INDEX is not None:
        candidates.append(MIC_INDEX)
    else:
        pulse_idxs = []
        usb_idxs = []
        other = []
        for i in range(audio.get_device_count()):
            d = audio.get_device_info_by_index(i)
            if d["maxInputChannels"] <= 0:
                continue
            name = d["name"]
            if name in ("pulse", "default") or "pulse" in name.lower():
                pulse_idxs.append(i)
            elif "USB" in name or "usb" in name:
                usb_idxs.append(i)
            else:
                other.append(i)
        candidates.extend(pulse_idxs)
        candidates.extend(usb_idxs)
        candidates.extend(other)
        candidates.append(None)

    last_err = None
    for idx in candidates:
        for rate in (16000, 48000, 44100):
            kwargs = dict(
                format=pyaudio.paInt16,
                channels=1,
                rate=rate,
                input=True,
                frames_per_buffer=1280 if rate == 16000 else max(1024, int(rate * 0.08)),
            )
            if idx is not None:
                kwargs["input_device_index"] = idx
            try:
                stream = audio.open(**kwargs)
                name = "default"
                if idx is not None:
                    name = audio.get_device_info_by_index(idx).get("name", str(idx))
                print(f"마이크: index={idx} ({name}) rate={rate}")
                return stream, rate
            except OSError as e:
                last_err = e
    raise RuntimeError(f"마이크를 열 수 없습니다: {last_err}")


def to_16k(pcm: np.ndarray, rate: int) -> np.ndarray:
    if rate == 16000:
        return pcm.astype(np.int16, copy=False)
    # 선형 보간 리샘플 (단순 [::n] 은 44100에서 부정확할 수 있음)
    target_len = max(1, int(round(len(pcm) * 16000 / rate)))
    if target_len == len(pcm):
        return pcm.astype(np.int16, copy=False)
    x_old = np.linspace(0.0, 1.0, num=len(pcm), endpoint=False)
    x_new = np.linspace(0.0, 1.0, num=target_len, endpoint=False)
    out = np.interp(x_new, x_old, pcm.astype(np.float32))
    return np.clip(out, -32768, 32767).astype(np.int16)


def resolve_stt_mic_index(audio: pyaudio.PyAudio) -> int | None:
    """STT용 마이크 = 호출어와 같은 USB/Pulse 우선."""
    if MIC_INDEX is not None:
        return MIC_INDEX
    pulse_idxs: list[int] = []
    usb_idxs: list[int] = []
    for i in range(audio.get_device_count()):
        d = audio.get_device_info_by_index(i)
        if d["maxInputChannels"] <= 0:
            continue
        name = str(d.get("name", "")).lower()
        if "pulse" in name or name in ("default", "pulse"):
            pulse_idxs.append(i)
        elif "usb" in name:
            usb_idxs.append(i)
    for group in (pulse_idxs, usb_idxs):
        if group:
            return group[0]
    return None


def configure_recognizer(recognizer: sr.Recognizer) -> None:
    """질문 듣기 — 작은 소리·속도 균형."""
    if STT_QUIET_MODE or STT_ENERGY_THRESHOLD > 0:
        recognizer.dynamic_energy_threshold = False
        recognizer.energy_threshold = STT_ENERGY_THRESHOLD if STT_ENERGY_THRESHOLD > 0 else 260
    else:
        recognizer.dynamic_energy_threshold = True
        recognizer.dynamic_energy_adjustment_damping = 0.1
        recognizer.dynamic_energy_ratio = 1.12
    recognizer.pause_threshold = STT_PAUSE_THRESHOLD
    recognizer.non_speaking_duration = STT_NON_SPEAKING_SEC
    recognizer.phrase_threshold = STT_PHRASE_THRESHOLD
    recognizer.operation_timeout = None


def _stt_record_limit_sec() -> float:
    """녹음 최대 길이 — phrase와 max_record 중 작은 값."""
    return min(STT_PHRASE_LIMIT_SEC, STT_MAX_RECORD_SEC)


def _prepare_recognizer_for_listen(recognizer: sr.Recognizer) -> None:
    """듣기 직전 — 고정 감도, 말 중간 멈춤에 잘리지 않게 pause 여유."""
    configure_recognizer(recognizer)
    recognizer.dynamic_energy_threshold = False
    recognizer.pause_threshold = max(STT_PAUSE_THRESHOLD, STT_MIN_PAUSE_SEC)
    recognizer.non_speaking_duration = max(STT_NON_SPEAKING_SEC, 0.45)


def _apply_stt_energy_after_ambient(recognizer: sr.Recognizer, noisy_warn: bool = False) -> None:
    """주변소음 보정 후 에너지 임계값 확정."""
    global _stt_noisy_background
    if recognizer.energy_threshold >= STT_NOISY_THRESHOLD:
        _stt_noisy_background = True
        cap = STT_ENERGY_THRESHOLD if STT_ENERGY_THRESHOLD > 0 else 400
        # 배경음이 크면 상한만 쓰고 최소값으로 올리지 않음 — 말 끝 감지가 늦어지는 주원인
        recognizer.energy_threshold = min(recognizer.energy_threshold, cap)
        if noisy_warn:
            print(
                "⚠ 배경 소리가 큽니다 (노래/TV). "
                "음악 줄이거나 마이크 15cm 안에서 또렷하게 말하세요."
            )
    elif STT_QUIET_MODE and STT_ENERGY_THRESHOLD > 0:
        cap = STT_ENERGY_THRESHOLD
        recognizer.energy_threshold = min(recognizer.energy_threshold, cap)
        _stt_noisy_background = False
    else:
        _stt_noisy_background = False
    if not _stt_noisy_background:
        recognizer.dynamic_energy_threshold = False


def calibrate_stt_ambient(recognizer: sr.Recognizer) -> None:
    """시작 시 주변 소음 보정. 배경음악 크면 동적 감도로 전환."""
    global _stt_ambient_calibrated
    if _stt_ambient_calibrated or STT_AMBIENT_SEC <= 0:
        _stt_ambient_calibrated = True
        if STT_QUIET_MODE and STT_ENERGY_THRESHOLD > 0 and not _stt_noisy_background:
            recognizer.energy_threshold = STT_ENERGY_THRESHOLD
        return
    mic_kwargs: dict = {}
    if STT_MIC_INDEX is not None:
        mic_kwargs["device_index"] = STT_MIC_INDEX
    print(f"STT 주변소음 보정 ({STT_AMBIENT_SEC}s)...")
    with sr.Microphone(**mic_kwargs) as source:
        recognizer.adjust_for_ambient_noise(source, duration=STT_AMBIENT_SEC)
    _apply_stt_energy_after_ambient(recognizer, noisy_warn=True)
    _stt_ambient_calibrated = True


def recalibrate_stt_before_listen(recognizer: sr.Recognizer, source) -> None:
    """질문 듣기 직전 짧게 재보정 — 같은 마이크 소스에서 수행."""
    dur = min(STT_AMBIENT_SEC, 0.18)
    if dur <= 0:
        return
    recognizer.adjust_for_ambient_noise(source, duration=dur)
    _apply_stt_energy_after_ambient(recognizer, noisy_warn=False)


def _listen_smart_end(recognizer: sr.Recognizer, source) -> sr.AudioData:
    """
    주변소음(팬) baseline 추적 → 말하기 시작/끝 감지.
    고정 임계값만 쓰면 팬이 계속 '말하는 중'으로 잡힘.
    """
    import math

    pause_sec = max(STT_PAUSE_THRESHOLD, STT_NON_SPEAKING_SEC)
    record_limit = _stt_record_limit_sec()
    energy_threshold = recognizer.energy_threshold
    chunk = source.CHUNK
    sample_width = source.SAMPLE_WIDTH
    sample_rate = source.SAMPLE_RATE
    seconds_per_buffer = float(chunk) / sample_rate
    pause_buffers = max(1, int(math.ceil(pause_sec / seconds_per_buffer)))
    min_speech_buffers = max(1, int(math.ceil(0.2 / seconds_per_buffer)))

    elapsed = 0.0
    deadline = STT_TIMEOUT_SEC if STT_TIMEOUT_SEC > 0 else float("inf")
    ambient = float(energy_threshold)
    ambient_alpha = 0.4

    def _rms(buf: bytes) -> int:
        return audioop.rms(buf, sample_width)

    def _speech_start_thr() -> float:
        return max(energy_threshold, ambient * STT_SPEECH_START_RATIO)

    def _speech_end_thr(peak: float) -> float:
        return max(ambient * 1.15, peak * STT_SILENCE_PEAK_RATIO)

    # 말 시작 대기 — 주변소음 baseline 학습
    pre_frames: list[bytes] = []
    while True:
        if elapsed > deadline:
            raise sr.WaitTimeoutError()
        buffer = source.stream.read(chunk)
        if not buffer:
            break
        elapsed += seconds_per_buffer
        energy = _rms(buffer)
        ambient = ambient * (1 - ambient_alpha) + energy * ambient_alpha
        pre_frames.append(buffer)
        if energy >= _speech_start_thr():
            break

    frames = list(pre_frames)
    peak = max(_rms(b) for b in pre_frames) if pre_frames else 0
    record_t = 0.0
    pause_count = 0
    speech_buffers = 0

    while record_t < record_limit:
        buffer = source.stream.read(chunk)
        if not buffer:
            break
        frames.append(buffer)
        record_t += seconds_per_buffer
        energy = _rms(buffer)
        peak = max(peak, energy)
        if energy < peak * 0.92:
            ambient = ambient * 0.88 + energy * 0.12

        if energy >= _speech_start_thr() * 0.92:
            speech_buffers += 1

        quiet = energy < _speech_end_thr(peak)
        if quiet and speech_buffers >= min_speech_buffers:
            pause_count += 1
            if pause_count >= pause_buffers:
                break
        else:
            pause_count = 0

    return sr.AudioData(b"".join(frames), sample_rate, sample_width)


def _listen_for_speech(recognizer: sr.Recognizer, source) -> sr.AudioData:
    """말 끝날 때까지 녹음."""
    if STT_SMART_END:
        return _listen_smart_end(recognizer, source)
    return recognizer.listen(
        source,
        timeout=STT_TIMEOUT_SEC,
        phrase_time_limit=_stt_record_limit_sec(),
    )


def _listen_with_feedback(recognizer: sr.Recognizer, source) -> sr.AudioData:
    """듣는 동안 피드백 (· = 듣는 중)."""
    stop = threading.Event()

    def _timer() -> None:
        while not stop.wait(0.4):
            print("·", end="", flush=True)

    t = threading.Thread(target=_timer, daemon=True)
    t.start()
    try:
        return _listen_for_speech(recognizer, source)
    finally:
        stop.set()
        t.join(timeout=0.5)


_last_stt_engine: str = "web"


def _stt_cloud_api_key() -> str:
    return (
        os.environ.get("STT_CLOUD_API_KEY", "").strip()
        or os.environ.get("YOUTUBE_API_KEY", "").strip()
        or GOOGLE_API_KEY.strip()
    )


def _stt_effective_backend() -> str:
    backend = (STT_BACKEND or "web").strip().lower()
    return backend if backend in ("web", "cloud") else "web"


def _stt_phrase_hints() -> list[str]:
    """Cloud STT speechContexts — 집 기기 이름 위주."""
    hints = [
        "자비스",
        "메인등",
        "거실",
        "아기방",
        "안방",
        "주방",
        "티비",
        "텔레비전",
        "에어컨",
        "스탠드형",
        "세탁기",
        "불 켜",
        "불 꺼",
        "드라마",
    ]
    try:
        ha = _ha_api()
        for eid, meta in ha.load_entities_map().items():
            if not meta.get("enabled"):
                continue
            name = str(meta.get("name") or "").strip()
            if name and name not in hints:
                hints.append(name)
    except Exception:
        pass
    seen: set[str] = set()
    out: list[str] = []
    for h in hints:
        h = h.strip()
        if h and h not in seen:
            seen.add(h)
            out.append(h)
    return out[:100]


def _recognize_web(recognizer: sr.Recognizer, audio_data: sr.AudioData) -> str:
    return recognizer.recognize_google(audio_data, language=STT_LANGUAGE)


def _recognize_cloud(recognizer: sr.Recognizer, audio_data: sr.AudioData) -> str:
    key = _stt_cloud_api_key()
    if not key:
        raise RuntimeError(
            "Cloud STT API 키 없음 (jarvis.env STT_CLOUD_API_KEY 또는 GOOGLE_API_KEY)"
        )
    raw = audio_data.get_raw_data(convert_rate=16000, convert_width=2)
    config: dict = {
        "encoding": "LINEAR16",
        "sampleRateHertz": 16000,
        "languageCode": STT_LANGUAGE,
        "model": STT_CLOUD_MODEL or "command_and_search",
        "enableAutomaticPunctuation": False,
    }
    phrases = _stt_phrase_hints()
    if phrases:
        config["speechContexts"] = [{"phrases": phrases, "boost": 10.0}]
    payload = {
        "config": config,
        "audio": {"content": base64.b64encode(raw).decode("ascii")},
    }
    url = (
        "https://speech.googleapis.com/v1/speech:recognize?key="
        + urllib.parse.quote(key, safe="")
    )
    req = urllib.request.Request(
        url,
        data=json.dumps(payload).encode(),
        method="POST",
        headers={"Content-Type": "application/json"},
    )
    try:
        with urllib.request.urlopen(req, timeout=20) as resp:
            data = json.loads(resp.read().decode())
    except urllib.error.HTTPError as e:
        body = e.read()[:400].decode(errors="replace")
        raise RuntimeError(f"Cloud STT HTTP {e.code}: {body}") from e
    results = data.get("results") or []
    if not results:
        raise sr.UnknownValueError("Cloud STT: 결과 없음")
    alts = results[0].get("alternatives") or []
    text = (alts[0].get("transcript") if alts else "") or ""
    text = str(text).strip()
    if not text:
        raise sr.UnknownValueError("Cloud STT: transcript 비어 있음")
    return text


def transcribe_audio(
    recognizer: sr.Recognizer, audio_data: sr.AudioData
) -> tuple[str, str]:
    """음성 → 텍스트. (text, engine_label) — web | cloud | web(fallback)."""
    global _last_stt_engine
    backend = _stt_effective_backend()
    if backend == "cloud":
        try:
            text = _recognize_cloud(recognizer, audio_data)
            _last_stt_engine = "cloud"
            return text, "cloud"
        except Exception as e:
            print(f"[STT cloud 실패] {e}")
            if not STT_CLOUD_FALLBACK:
                raise
            text = _recognize_web(recognizer, audio_data)
            _last_stt_engine = "web(fallback)"
            return text, "web(fallback)"
    text = _recognize_web(recognizer, audio_data)
    _last_stt_engine = "web"
    return text, "web"


def _mic_gain_pactl_arg(gain: float) -> str:
    """STT_MIC_GAIN 1.0=100%, 1.3=130%, 2.0=200% (PulseAudio % 형식)."""
    pct = max(50, min(200, int(round(gain * 100))))
    return f"{pct}%"


def prepare_mic_for_listen() -> None:
    """USB 마이크 감도 올리기."""
    if not PULSE_SOURCE:
        return
    subprocess.run(
        ["pactl", "set-source-mute", PULSE_SOURCE, "0"],
        check=False,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    if STT_MIC_GAIN > 0:
        subprocess.run(
            [
                "pactl",
                "set-source-volume",
                PULSE_SOURCE,
                _mic_gain_pactl_arg(STT_MIC_GAIN),
            ],
            check=False,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )


def _is_wake_echo(text: str) -> bool:
    """호출어만 말한 경우 질문으로 보지 않음."""
    c = _compact(text).lower()
    noise = ("alexa", "알렉사", "alex", "timer", "타이머", "weather", "웨더")
    return any(n in c for n in noise) and len(c) <= 12


def _answer_looks_cut_off(text: str) -> bool:
    """Gemini가 중간에 끊은 답(실시간→실) 감지."""
    t = text.strip()
    if len(t) < 8:
        return True
    if t[-1] in ".!?。)" or t[-1].isdigit() or t[-1] in "도%℃":
        return False
    return True


def _take_complete_sentences(buffer: str) -> tuple[list[str], str]:
    """스트리밍 버퍼에서 완성된 문장만 분리."""
    sentences: list[str] = []
    rest = buffer
    while True:
        m = re.search(r"(?<=[.!?。])(?:\s+|$)", rest)
        if not m:
            break
        sent = rest[:m.end()].strip()
        rest = rest[m.end():]
        if sent:
            sentences.append(sent)
    return sentences, rest


async def _play_audio_async(path: str) -> bool:
    """paplay 비동기 — 중단 시 False."""
    if _speech_interrupt.is_set():
        return False
    env = os.environ.copy()
    if PULSE_SINK:
        env["PULSE_SINK"] = PULSE_SINK
    cmd = [
        "paplay",
        f"--latency-msec={max(10, TTS_PLAY_LATENCY_MS)}",
        path,
    ]
    try:
        proc = await asyncio.create_subprocess_exec(
            *cmd,
            env=env,
            stdout=asyncio.subprocess.DEVNULL,
            stderr=asyncio.subprocess.DEVNULL,
        )
        with _tts_play_lock:
            global _tts_play_proc
            _tts_play_proc = proc
        while proc.returncode is None:
            if _speech_interrupt.is_set():
                proc.terminate()
                await proc.wait()
                return False
            await asyncio.sleep(0.05)
        with _tts_play_lock:
            if _tts_play_proc is proc:
                _tts_play_proc = None
        if proc.returncode == 0 and not _speech_interrupt.is_set():
            return True
    except OSError as e:
        print(f"paplay 실패, pygame으로 대체: {e}")
    return await asyncio.to_thread(_play_audio_file, path)


async def _async_synth_to_path(text: str) -> str:
    """문장 하나 → 오디오 파일 (비동기)."""
    root = os.path.join(BASE_DIR, f"reply_a_{uuid.uuid4().hex[:8]}")
    if TTS_BACKEND == "gemini":
        wav_path = f"{root}.wav"
        await asyncio.to_thread(_synthesize_gemini, text, wav_path)
        return wav_path
    mp3_path = f"{root}.mp3"
    await _synthesize_edge(text, mp3_path)
    return mp3_path


async def _async_gemini_voice_reply(user_text: str) -> tuple[str, _TurnUsage]:
    """Gemini 스트리밍 + TTS 파이프라인: 재생 중 다음 문장 합성."""
    _begin_speech(interruptible=True)
    try:
        max_tokens = GEMINI_VOICE_MAX_TOKENS
        sentence_q: asyncio.Queue[str | None] = asyncio.Queue()
        answer_parts: list[str] = []
        last_chunk = None

        async def producer() -> None:
            nonlocal last_chunk
            buffer = ""
            client = _get_gemini_text_client()
            cfg = _gemini_generate_config(max_tokens=max_tokens)
            contents = _build_gemini_contents(user_text)
            with warnings.catch_warnings():
                warnings.simplefilter("ignore")
                with open(os.devnull, "w") as devnull:
                    with contextlib.redirect_stderr(devnull):
                        stream = await client.aio.models.generate_content_stream(
                            model=GEMINI_MODEL,
                            contents=contents,
                            config=cfg,
                        )
                        async for chunk in stream:
                            if _speech_interrupt.is_set():
                                break
                            last_chunk = chunk
                            if not chunk.text:
                                continue
                            answer_parts.append(chunk.text)
                            buffer += chunk.text
                            done, buffer = _take_complete_sentences(buffer)
                            for sent in done:
                                await sentence_q.put(sent)
            if buffer.strip() and not _speech_interrupt.is_set():
                await sentence_q.put(buffer.strip())
            await sentence_q.put(None)

        async def consumer() -> None:
            pending: asyncio.Task[str] | None = None
            while not _speech_interrupt.is_set():
                try:
                    sent = await asyncio.wait_for(sentence_q.get(), timeout=0.05)
                except asyncio.TimeoutError:
                    continue
                if sent is None:
                    break
                clean = _clean_speech_text(sent)
                if not clean:
                    continue
                print(f"자비스: {clean}", flush=True)
                synth_task = asyncio.create_task(_async_synth_to_path(clean))
                if pending is not None:
                    path = await pending
                    if not await _play_audio_async(path):
                        synth_task.cancel()
                        break
                    _remove_tts_file(path, None)
                pending = synth_task
            if pending is not None and not _speech_interrupt.is_set():
                path = await pending
                if await _play_audio_async(path):
                    _remove_tts_file(path, None)
            elif pending is not None:
                pending.cancel()

        await asyncio.gather(producer(), consumer())
        turn_u = _TurnUsage()
        if last_chunk is not None:
            turn_u.add(print_usage_genai(last_chunk))
        answer = "".join(answer_parts).strip()
        return answer, turn_u
    finally:
        _end_speech()


def _run_async_gemini_voice_reply(user_text: str) -> tuple[str, _TurnUsage]:
    return asyncio.run(_async_gemini_voice_reply(user_text))


def _speak_quick(text: str) -> None:
    """한 문장 빠른 재생 (스트리밍 답변용)."""
    clean = _clean_speech_text(text)
    if not clean:
        return
    _begin_speech(interruptible=True)
    try:
        print(f"자비스: {clean}")
        path = _synth_chunk_path(os.path.join(BASE_DIR, "reply_stream"), None, 0, clean)
        _play_audio_file(path)
        _remove_tts_file(path, None)
    finally:
        _end_speech()


async def _speak_quick_async(text: str) -> None:
    clean = _clean_speech_text(text)
    if not clean:
        return
    _begin_speech(interruptible=True)
    try:
        print(f"자비스: {clean}", flush=True)
        path = await _async_synth_to_path(clean)
        await _play_audio_async(path)
        _remove_tts_file(path, None)
    finally:
        _end_speech()


def _gemini_reply(
    user_text: str, *, speak_while_streaming: bool = False
) -> tuple[str, _TurnUsage]:
    """HA Function Calling(비스트림). 도구를 썼으면 TTS만 재생. 잡담은 받은 답을 바로 말한다."""
    max_tokens = GEMINI_VOICE_MAX_TOKENS
    turn_u = _TurnUsage()

    _reset_ha_tool_calls()
    try:
        response = _genai_generate_text(
            user_text,
            max_tokens=max(max_tokens, 384),
            with_ha_tools=True,
        )
        turn_u.add(print_usage_genai(response, label="ha-tools"))
        used_tools = bool(_ha_tool_calls)
        answer = (response.text or "").strip() if response else ""
    except Exception as e:
        print(f"[HA tools] 실패, 일반 대화로 폴백: {e}")
        used_tools = False
        answer = ""
        if GEMINI_STREAM and speak_while_streaming:
            return _run_async_gemini_voice_reply(user_text)
        response = _genai_generate_text(user_text, max_tokens=max_tokens)
        turn_u.add(print_usage_genai(response))
        answer = (response.text or "").strip()

    if used_tools and not answer:
        answer = "기기 조작은 했는데 답을 못 만들었어요."

    if not answer:
        return "답을 만들지 못했습니다.", turn_u

    if _answer_looks_cut_off(answer) and not used_tools:
        print("(답이 끊긴 것 같아 다시 생성)")
        retry = _genai_generate_text(
            f"질문: {user_text}\n한두 문장으로 끝까지 완결하게 답해.",
            max_tokens=160,
            system="자비스. 짧고 완결된 한국어 한두 문장.",
            use_session_history=False,
        )
        turn_u.add(print_usage_genai(retry, label="retry"))
        if retry.text and len(retry.text.strip()) > len(answer):
            answer = retry.text.strip()

    if speak_while_streaming:
        asyncio.run(_speak_quick_async(answer))
    return answer, turn_u


def process_user_text(user_text: str, *, stt_engine: str | None = None) -> None:
    """음성/텍스트 공통: 메모리 → 장치 명령 → 제미나이. 질문/답변 로그 분리 저장."""
    turn_id = log_question(user_text, source="voice", stt_engine=stt_engine)

    local = handle_memory_command(user_text)
    if local is not None:
        print("(로컬 메모리, API 호출 없음)")
        log_answer(local, turn_id=turn_id, kind="memory")
        speak(local, interruptible=False)
        return

    device = handle_device_command(user_text)
    if device is not None:
        print("(로컬 장치 명령, API 호출 없음)")
        log_answer(device, turn_id=turn_id, kind="device")
        speak(device, interruptible=False)
        _commit_youtube_play()
        return

    print("생각 중...")
    answer, turn_u = _gemini_reply(
        user_text, speak_while_streaming=GEMINI_STREAM
    )
    usage = turn_u.merged()
    append_session_turn(user_text, answer)
    log_answer(answer, turn_id=turn_id, kind="gemini", usage=usage)
    if GEMINI_STREAM:
        pass  # 이미 스트리밍 중 재생 완료
    else:
        speak(answer, prefetch=_TtsPrefetch(answer))


def listen_command(
    recognizer: sr.Recognizer,
    mic_stream=None,
) -> bool:
    """호출어 이후 명령 듣기 → 처리. 질문 인식 성공 시 True."""
    global _pending_interrupt_listen
    load_runtime_config_if_changed(verbose=True)

    paused_wake_mic = mic_stream is not None
    if paused_wake_mic:
        _pause_wake_mic(mic_stream)

    heard_success = False
    try:
        prepare_mic_for_listen()

        mic_kwargs: dict = {}
        if STT_MIC_INDEX is not None:
            mic_kwargs["device_index"] = STT_MIC_INDEX

        with sr.Microphone(**mic_kwargs) as source:
            interrupt_round = 0
            while interrupt_round < 4:
                interrupt_round += 1
                if interrupt_round > 1:
                    print("답변을 멈췄어요. 새 질문 말해 주세요.")
                if STT_RECAL_BEFORE_LISTEN and interrupt_round == 1:
                    recalibrate_stt_before_listen(recognizer, source)
                _prepare_recognizer_for_listen(recognizer)
                play_beep()
                if STT_POST_BEEP_DELAY_SEC > 0:
                    time.sleep(STT_POST_BEEP_DELAY_SEC)

                record_limit = _stt_record_limit_sec()
                effective_pause = max(STT_PAUSE_THRESHOLD, STT_NON_SPEAKING_SEC)
                print(
                    f"[STT 준비] backend={_stt_effective_backend()} "
                    f"energy={recognizer.energy_threshold:.0f} "
                    f"pause={effective_pause}s 녹음≤{record_limit:.0f}s "
                    f"smart=on start×{STT_SPEECH_START_RATIO} peak×{STT_SILENCE_PEAK_RATIO} "
                    f"mic={_mic_gain_pactl_arg(STT_MIC_GAIN)}"
                )

                round_heard = False
                for attempt in range(max(1, STT_RETRY_MAX)):
                    if attempt == 0:
                        print("지금 말하세요", end="", flush=True)
                    else:
                        print("다시 말하세요", end="", flush=True)
                    try:
                        if paused_wake_mic:
                            _pause_wake_mic(mic_stream)
                        audio_data = _listen_with_feedback(recognizer, source)
                        if paused_wake_mic:
                            _restart_wake_mic(mic_stream)
                        print(" 인식 중…", flush=True)
                        t_stt = time.time()
                        user_text, stt_engine = asyncio.run(
                            asyncio.to_thread(
                                transcribe_audio, recognizer, audio_data
                            )
                        )
                        print(
                            f"(STT {time.time() - t_stt:.1f}s engine={stt_engine})",
                            flush=True,
                        )
                        if _is_wake_echo(user_text):
                            print(
                                f"(호출어만 들림: {user_text}) — 질문을 말씀해 주세요."
                            )
                            continue
                        print(f"나: {user_text}")
                        process_user_text(user_text, stt_engine=stt_engine)
                        heard_success = True
                        round_heard = True
                        break
                    except sr.WaitTimeoutError:
                        print("아무 말씀도 없으셔서 대기 모드로 돌아갑니다.")
                        break
                    except sr.UnknownValueError:
                        print("목소리를 제대로 듣지 못했습니다.")
                    except Exception as e:
                        print(f"오류 발생: {e}")
                        break

                if _pending_interrupt_listen:
                    _pending_interrupt_listen = False
                    continue
                if round_heard:
                    break
                break
        return heard_success
    finally:
        if paused_wake_mic:
            _restart_wake_mic(mic_stream)


def _porcupine_phrase_filename(phrase: str) -> str:
    safe = re.sub(r"[^\w가-힣]+", "_", phrase.strip(), flags=re.UNICODE)
    safe = safe.strip("_") or "keyword"
    return f"{safe}_raspberry-pi.ppn"


def _ensure_porcupine_model() -> str:
    path = PORCUPINE_MODEL_PATH
    if os.path.isfile(path) and os.path.getsize(path) > 1000:
        return path
    os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
    print(f"한국어 Porcupine 모델 다운로드 중 → {path}")
    import urllib.request

    urllib.request.urlretrieve(PORCUPINE_MODEL_URL, path)
    if not os.path.isfile(path) or os.path.getsize(path) < 1000:
        raise SystemExit(f"Porcupine 한국어 모델 다운로드 실패: {path}")
    return path


def resolve_porcupine_keyword_paths() -> list[str]:
    """환경에 지정된 .ppn 또는 문구로 학습/확보한 경로 목록."""
    if PORCUPINE_KEYWORD_PATH:
        paths = [p.strip() for p in PORCUPINE_KEYWORD_PATH.split(",") if p.strip()]
        missing = [p for p in paths if not os.path.isfile(p)]
        if missing:
            raise SystemExit(
                "PORCUPINE_KEYWORD_PATH 파일을 찾을 수 없습니다:\n  "
                + "\n  ".join(missing)
            )
        return paths

    phrases = [p.strip() for p in PORCUPINE_PHRASES.split(",") if p.strip()]
    if not phrases:
        raise SystemExit("PORCUPINE_PHRASES 가 비어 있습니다.")

    os.makedirs(PORCUPINE_DIR, exist_ok=True)
    paths: list[str] = []
    need_train: list[tuple[str, str]] = []
    for phrase in phrases:
        out = os.path.join(PORCUPINE_DIR, _porcupine_phrase_filename(phrase))
        paths.append(out)
        if not os.path.isfile(out) or os.path.getsize(out) < 100:
            need_train.append((phrase, out))

    if not need_train:
        return paths

    if not PICOVOICE_ACCESS_KEY:
        raise SystemExit(
            "한국어 호출어(.ppn)가 없습니다. jarvis.env 에 다음을 넣으세요:\n"
            "  PICOVOICE_ACCESS_KEY=...  (https://console.picovoice.ai/)\n"
            "또는 Console에서 만든 .ppn 경로를:\n"
            "  PORCUPINE_KEYWORD_PATH=/path/to/keyword_raspberry-pi.ppn"
        )

    from pvporcupine._util import pv_train_model

    for phrase, out in need_train:
        print(f"Porcupine 키워드 학습 중: 「{phrase}」 → {out}")
        pv_train_model(
            access_key=PICOVOICE_ACCESS_KEY,
            output_path=out,
            language=PORCUPINE_LANGUAGE,
            phrase=phrase,
            platform="raspberry-pi",
        )
        if not os.path.isfile(out) or os.path.getsize(out) < 100:
            raise SystemExit(f"키워드 파일 생성 실패: {out}")
    return paths


def create_porcupine():
    """한국어 Porcupine 엔진."""
    import pvporcupine

    if not PICOVOICE_ACCESS_KEY:
        raise SystemExit(
            "WAKE_MODE=korean 에는 Picovoice Access Key가 필요합니다.\n"
            "  https://console.picovoice.ai/ 에서 발급 후\n"
            "  jarvis.env 에 PICOVOICE_ACCESS_KEY=... 를 넣고\n"
            "  systemctl --user restart jarvis"
        )
    model_path = _ensure_porcupine_model()
    keyword_paths = resolve_porcupine_keyword_paths()
    sensitivities = [PORCUPINE_SENSITIVITY] * len(keyword_paths)
    print(f"Porcupine 모델={model_path}")
    print(f"Porcupine 키워드={keyword_paths}")
    print(f"Porcupine sensitivity={PORCUPINE_SENSITIVITY}")
    return pvporcupine.create(
        access_key=PICOVOICE_ACCESS_KEY,
        model_path=model_path,
        keyword_paths=keyword_paths,
        sensitivities=sensitivities,
    )


def _wake_interrupt_worker() -> None:
    """TTS/답변 중 호출어 → 말 끊고 새 질문 대기 (대기 중 연속 호출 거부는 wait_wake가 담당)."""
    global _pending_interrupt_listen, _last_wake_at
    wake_buf = np.zeros(0, dtype=np.int16)
    hit_frames = 0
    while not _interrupt_watcher_stop.is_set():
        if not _speech_active or not _speech_interruptible or _wake_mic_paused:
            time.sleep(0.05)
            wake_buf = np.zeros(0, dtype=np.int16)
            hit_frames = 0
            continue
        load_runtime_config_if_changed()
        ctx = _wake_interrupt_ctx
        if not ctx:
            time.sleep(0.1)
            continue
        mic_stream = ctx.get("mic_stream")
        mic_rate = ctx.get("mic_rate", 16000)
        chunk = ctx.get("chunk", 1280)
        oww_model = ctx.get("oww_model")
        if mic_stream is None or oww_model is None:
            time.sleep(0.1)
            continue
        if mic_stream.is_stopped():
            time.sleep(0.05)
            continue
        try:
            pcm = np.frombuffer(
                mic_stream.read(chunk, exception_on_overflow=False),
                dtype=np.int16,
            )
        except OSError:
            time.sleep(0.1)
            continue
        pcm16 = to_16k(pcm, mic_rate)
        wake_buf = np.concatenate([wake_buf, pcm16])
        while len(wake_buf) >= WAKE_FRAME:
            frame = wake_buf[:WAKE_FRAME]
            wake_buf = wake_buf[WAKE_FRAME:]
            prediction = oww_model.predict(frame)
            scored = [
                (model_name, float(prediction.get(model_name, 0.0) or 0.0))
                for model_name in OWW_WAKE_LABELS
            ]
            winner, score = max(scored, key=lambda x: x[1])
            if score >= WAKE_STRONG_SCORE:
                hit_frames = WAKE_MIN_FRAMES
            elif score > WAKE_THRESHOLD:
                hit_frames += 1
            else:
                hit_frames = 0
            if hit_frames < WAKE_MIN_FRAMES:
                continue
            detail = " ".join(f"{n}={v:.3f}" for n, v in scored)
            print(
                f"\n[대답 중 호출!] winner={winner} score={score:.3f} "
                f"({detail}) — 끊고 새 질문 받기",
                flush=True,
            )
            _pending_interrupt_listen = True
            _last_wake_at = time.time()
            request_speech_interrupt()
            try:
                oww_model.reset()
            except Exception:
                pass
            hit_frames = 0
            wake_buf = np.zeros(0, dtype=np.int16)
            break


def _start_interrupt_watcher() -> None:
    threading.Thread(
        target=_wake_interrupt_worker,
        daemon=True,
        name="jarvis-wake-int",
    ).start()


def wait_wake_jarvis(
    mic_stream,
    mic_rate: int,
    chunk: int,
    recognizer: sr.Recognizer,
    oww_model: Model,
) -> None:
    """영어 openWakeWord 호출어."""
    global _last_wake_at
    wake_buf = np.zeros(0, dtype=np.int16)
    last_dbg = time.time()
    dbg_max = 0.0
    hit_frames = 0
    print(f"모드=openWakeWord models={OWW_WAKE_LABELS} 임계={WAKE_THRESHOLD} 연속={WAKE_MIN_FRAMES}")
    print(f"완료! {WAKE_PROMPT} (종료: Ctrl+C)")

    while True:
        load_runtime_config_if_changed()
        mic_stream = _wake_interrupt_ctx.get("mic_stream", mic_stream)
        chunk = _wake_interrupt_ctx.get("chunk", chunk)
        mic_rate = _wake_interrupt_ctx.get("mic_rate", mic_rate)
        try:
            pcm = np.frombuffer(
                mic_stream.read(chunk, exception_on_overflow=False),
                dtype=np.int16,
            )
        except OSError as e:
            print(f"[wake] 마이크 읽기 오류: {e} — 복구 시도")
            if not _restart_wake_mic(mic_stream):
                raise
            continue
        pcm16 = to_16k(pcm, mic_rate)
        wake_buf = np.concatenate([wake_buf, pcm16])

        while len(wake_buf) >= WAKE_FRAME:
            frame = wake_buf[:WAKE_FRAME]
            wake_buf = wake_buf[WAKE_FRAME:]
            prediction = oww_model.predict(frame)
            scored = [
                (model_name, float(prediction.get(model_name, 0.0) or 0.0))
                for model_name in OWW_WAKE_LABELS
            ]
            winner, score = max(scored, key=lambda x: x[1])
            dbg_max = max(dbg_max, score)
            media_on = _is_media_playing()
            thr, strong = _wake_hit_thresholds(media_on)
            if score >= strong:
                hit_frames = WAKE_MIN_FRAMES
            elif score > thr:
                hit_frames += 1
            else:
                hit_frames = 0
            if hit_frames < WAKE_MIN_FRAMES:
                continue

            now = time.time()
            if not media_on and now - _last_wake_at < MIN_WAKE_INTERVAL_SEC:
                if WAKE_DEBUG:
                    print(
                        f"[wake 무시] {MIN_WAKE_INTERVAL_SEC:.0f}초 이내 재호출 "
                        f"({now - _last_wake_at:.1f}초)"
                    )
                hit_frames = 0
                continue

            _last_wake_at = now
            detail = " ".join(f"{n}={v:.3f}" for n, v in scored)
            if media_on:
                print(
                    f"\n[재생 중 호출!] winner={winner} score={score:.3f} "
                    f"({detail}) — 음악 끊고 질문 받기",
                    flush=True,
                )
                _stop_media_playback()
                time.sleep(0.35)
            else:
                print(
                    f"\n[감지됨!] winner={winner} score={score:.3f} ({detail})",
                    flush=True,
                )
            wake_buf = np.zeros(0, dtype=np.int16)
            hit_frames = 0
            dbg_max = 0.0
            heard = listen_command(recognizer, mic_stream)
            if not heard:
                print(
                    "※ 호출어를 연속으로 부르지 마세요. "
                    "한 번 부르고 삐 소리 뒤에 질문하세요."
                )
            print(f"\n다시 대기 — {WAKE_PROMPT}")
            oww_model.reset()
            cooldown = WAKE_COOLDOWN_SEC
            if not heard:
                cooldown += LISTEN_FAIL_COOLDOWN_SEC
            time.sleep(cooldown)
            last_dbg = time.time()
            break

        if WAKE_DEBUG and time.time() - last_dbg >= 3.0:
            rms = float(np.sqrt(np.mean(pcm.astype(np.float64) ** 2))) if len(pcm) else 0.0
            print(f"[wake 디버그] 최근최대={dbg_max:.3f} mic_rms={rms:.0f}")
            dbg_max = 0.0
            last_dbg = time.time()


def wait_wake_korean(
    mic_stream,
    mic_rate: int,
    chunk: int,
    recognizer: sr.Recognizer,
) -> None:
    """한글 호출어: Picovoice Porcupine (로컬, STT 없음)."""
    porcupine = create_porcupine()
    frame_len = int(porcupine.frame_length)
    phrases = [p.strip() for p in PORCUPINE_PHRASES.split(",") if p.strip()]
    print(
        f"모드=korean Porcupine frame={frame_len} "
        f"sensitivity={PORCUPINE_SENSITIVITY}"
    )
    print(f"완료! {WAKE_PROMPT} (종료: Ctrl+C)")
    wake_buf = np.zeros(0, dtype=np.int16)
    last_dbg = time.time()

    try:
        while True:
            pcm = np.frombuffer(
                mic_stream.read(chunk, exception_on_overflow=False),
                dtype=np.int16,
            )
            pcm16 = to_16k(pcm, mic_rate)
            wake_buf = np.concatenate([wake_buf, pcm16])

            while len(wake_buf) >= frame_len:
                frame = wake_buf[:frame_len]
                wake_buf = wake_buf[frame_len:]
                result = int(porcupine.process(frame))
                if result < 0:
                    continue

                name = (
                    phrases[result]
                    if result < len(phrases)
                    else f"keyword[{result}]"
                )
                print(f"\n[감지됨!] Porcupine: {name}")
                wake_buf = np.zeros(0, dtype=np.int16)
                listen_command(recognizer, mic_stream)
                print(f"\n다시 대기 — {WAKE_PROMPT}")
                time.sleep(WAKE_COOLDOWN_SEC)
                last_dbg = time.time()
                break

            if WAKE_DEBUG and time.time() - last_dbg >= 3.0:
                rms = (
                    float(np.sqrt(np.mean(pcm.astype(np.float64) ** 2)))
                    if len(pcm)
                    else 0.0
                )
                print(f"[wake 디버그] porcupine 대기 mic_rms={rms:.0f}")
                last_dbg = time.time()
    finally:
        porcupine.delete()


def run_self_test() -> None:
    """호출어 없이 대화+TTS만 검증 (--test)."""
    print("=== 자비스 자가 테스트 (호출어 생략) ===")
    set_speaker_volume(SPEAKER_VOLUME)
    rebuild_chat()
    play_beep()
    print("비프음 재생 완료")
    process_user_text("테스트입니다. 한 줄로 대답해줘.")
    print("=== 테스트 완료 ===")


def main() -> None:
    set_speaker_volume(SPEAKER_VOLUME)
    prepare_mic_for_listen()
    mem_n = len(load_memories())
    print(f"설정파일: {ENV_PATH}")
    ensure_runtime_config_file()
    load_runtime_config_if_changed(verbose=True)
    print(f"실시간 설정: {RUNTIME_CONFIG_PATH} (수정 시 재시작 불필요)")
    ensure_ha_entities_file()
    print(
        "HA 엔티티 스위치: "
        f"{os.path.join(BASE_DIR, 'jarvis_ha_entities.json')} "
        "(enabled 저장 즉시 반영)"
    )
    print(f"WAKE_MODE={WAKE_MODE}  LANG_MODE={LANG_MODE}  STT={STT_LANGUAGE}")
    print(f"스피커 볼륨 ≈ {SPEAKER_VOLUME}%")
    print(
        f"TTS: backend={TTS_BACKEND} model={TTS_MODEL} "
        f"voice={TTS_VOICE} style={TTS_STYLE!r}"
    )
    if WAKE_MODE not in ("korean", "ko", "hangul", "한글", "porcupine"):
        print(
            f"호출어(openWakeWord): {OWW_WAKE_LABELS} "
            f"framework={OWW_INFERENCE_FRAMEWORK} 임계={WAKE_THRESHOLD}"
        )
    print(f"영구 기억: {mem_n}개 ({MEMORY_PATH})")
    print(f"대화 로그: {QUESTIONS_LOG} / {ANSWERS_LOG}")
    if not os.path.isfile(USAGE_SUMMARY_PATH):
        rebuild_usage_summary_from_logs()
    print_usage_summary_brief()
    rebuild_chat(load_logs=True)
    if _session_history:
        print(f"대화 맥락: 최근 {len(_session_history)}턴 로그에서 복원")
    print("TTS 워밍업…", flush=True)
    warmup_tts()

    audio = pyaudio.PyAudio()
    global STT_MIC_INDEX
    STT_MIC_INDEX = resolve_stt_mic_index(audio)
    mic_stream, mic_rate = open_mic_stream(audio)
    chunk = max(512, int(mic_rate * 0.08))
    global _wake_mic_paused
    _wake_mic_paused = False
    if STT_MIC_INDEX is not None:
        stt_name = audio.get_device_info_by_index(STT_MIC_INDEX).get("name", "?")
        print(f"STT 마이크: index={STT_MIC_INDEX} ({stt_name})")
    print(
        f"STT: backend={STT_BACKEND} model={STT_CLOUD_MODEL} "
        f"fallback={STT_CLOUD_FALLBACK} lang={STT_LANGUAGE} "
        f"timeout={STT_TIMEOUT_SEC}s record≤{_stt_record_limit_sec():.0f}s "
        f"mic_gain={STT_MIC_GAIN} quiet={STT_QUIET_MODE} "
        f"energy={STT_ENERGY_THRESHOLD}"
    )
    print(f"STT 실시간 전환: {RUNTIME_CONFIG_PATH} → stt_backend: web | cloud")
    recognizer = sr.Recognizer()
    configure_recognizer(recognizer)
    calibrate_stt_ambient(recognizer)

    try:
        if WAKE_MODE in ("korean", "ko", "hangul", "한글", "porcupine"):
            wait_wake_korean(mic_stream, mic_rate, chunk, recognizer)
        else:
            print("호출어 엔진(openWakeWord) 로딩 중...")
            oww_model = Model(
                wakeword_models=list(OWW_WAKE_PATHS),
                inference_framework=OWW_INFERENCE_FRAMEWORK,
            )
            _wake_interrupt_ctx.update(
                audio=audio,
                mic_stream=mic_stream,
                mic_rate=mic_rate,
                chunk=chunk,
                oww_model=oww_model,
            )
            _start_interrupt_watcher()
            wait_wake_jarvis(mic_stream, mic_rate, chunk, recognizer, oww_model)
    except KeyboardInterrupt:
        print("\n프로그램을 종료합니다.")
    finally:
        mic_stream.stop_stream()
        mic_stream.close()
        audio.terminate()


if __name__ == "__main__":
    import sys

    if "--test" in sys.argv:
        run_self_test()
    else:
        main()
