"""Live status website for the voice loop.

Serves a single self-contained page plus a Server-Sent-Events stream so you can
open a browser and watch, step by step: is it listening, what it heard, what it
thought/answered, how long each stage took, and whether anything errored.

Pure stdlib (``http.server``). Runs in a background thread so it never blocks
the asyncio pipeline.

Endpoints:
    GET /            -> the dashboard HTML
    GET /api/state   -> JSON snapshot (initial load / fallback polling)
    GET /events      -> text/event-stream live push
"""

from __future__ import annotations

import json
import logging
import queue
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

from .monitor import Monitor

log = logging.getLogger("wsai.dashboard")

def _speech_text(reply: str) -> str:
    """Return reply text exactly as authored for the TTS backend.

    The TTS backend itself understands bracketed emotion tags: known emotion
    tags steer delivery and are not spoken; non-emotion brackets are spoken.
    Do not rewrite a leading tag here, or the first emotion would be read aloud
    and lost before ``MeloTTS.synth`` can parse it."""
    return reply


def _thought_summary(reply: str) -> str:
    """A short '생각내용' line: the emotion/tone plan the bot chose for delivery,
    derived from the [감정] tags in its reply. This is the AI's decision about
    *how* to say the answer, shown between 들음 and 답변."""
    import re
    from .backends.emotion import match_emotion

    tags = re.findall(r"\[([^\[\]]*)\]", reply or "")
    emotions = [t.strip() for t in tags if match_emotion(t)]
    if emotions:
        return "감정 톤: " + " → ".join(emotions)
    return "감정 태그 없음 · 기본 톤으로 답변"


def _make_handler(dash: "Dashboard"):
    monitor = dash.monitor

    class Handler(BaseHTTPRequestHandler):
        # Quiet: don't spam the console with one line per request.
        def log_message(self, *args) -> None:  # noqa: D401
            return

        def _send(self, code: int, body: bytes, ctype: str) -> None:
            self.send_response(code)
            self.send_header("Content-Type", ctype)
            self.send_header("Content-Length", str(len(body)))
            self.send_header("Cache-Control", "no-store")
            self.end_headers()
            self.wfile.write(body)

        def do_GET(self) -> None:  # noqa: N802
            path = self.path.split("?", 1)[0]
            if path == "/" or path == "/index.html":
                self._send(200, PAGE.encode("utf-8"), "text/html; charset=utf-8")
            elif path == "/api/state":
                body = json.dumps(monitor.snapshot(), ensure_ascii=False).encode("utf-8")
                self._send(200, body, "application/json; charset=utf-8")
            elif path == "/api/prompt":
                self._handle_prompt_get()
            elif path == "/api/bot/state":
                self._send_json(dash.bot.state())
            elif path == "/api/bot/commands":
                self._send_json({"commands": dash.bot.drain()})
            elif path == "/api/bot/lists":
                import urllib.parse as _up
                q = _up.parse_qs(self.path.split("?", 1)[1] if "?" in self.path else "")
                gid = (q.get("guildId", [""])[0])
                self._send_json({"ok": True, "guildId": gid, "lists": dash.bot.get_lists(gid)})
            elif path == "/events":
                self._stream_events()
            else:
                self._send(404, b"not found", "text/plain; charset=utf-8")

        def do_POST(self) -> None:  # noqa: N802
            path = self.path.split("?", 1)[0]
            if path == "/api/stt":
                self._handle_stt()
            elif path == "/api/voice-turn":
                self._handle_voice_turn()
            elif path == "/api/prompt":
                self._handle_prompt_post()
            elif path == "/api/logs/clear":
                monitor.clear_events()
                self._send(200, json.dumps({"ok": True}).encode(), "application/json; charset=utf-8")
            elif path == "/api/logs/delete":
                self._handle_log_mutate("delete")
            elif path == "/api/logs/edit":
                self._handle_log_mutate("edit")
            elif path == "/api/bot/report":
                self._handle_bot_report()
            elif path == "/api/bot/select":
                self._handle_bot_select()
            elif path == "/api/bot/lists":
                self._handle_bot_lists()
            else:
                self._send(404, b"not found", "text/plain; charset=utf-8")

        def _read_body(self) -> bytes:
            try:
                length = int(self.headers.get("Content-Length", "0"))
            except ValueError:
                length = 0
            return self.rfile.read(length) if length > 0 else b""

        def _handle_voice_turn(self) -> None:
            """Discord voice bridge: utterance wav in -> reply wav out. The
            recognised/reply text ride along as URL-encoded response headers so
            the bot can log them; the body is the reply audio to play back."""
            import urllib.parse

            if dash.stt is None or dash.tts is None:
                self._send(503, json.dumps({"ok": False, "error": "voice loop not enabled"}).encode(),
                           "application/json; charset=utf-8")
                return
            raw = self._read_body()
            if not raw:
                self._send(400, json.dumps({"ok": False, "error": "empty upload"}).encode(),
                           "application/json; charset=utf-8")
                return
            speaker = urllib.parse.unquote(self.headers.get("X-User-Name", "") or "")
            guild = urllib.parse.unquote(self.headers.get("X-Guild-Name", "") or "")
            channel = urllib.parse.unquote(self.headers.get("X-Channel-Name", "") or "")
            try:
                res = dash.voice_turn(raw, speaker=speaker, guild=guild, channel=channel)
            except Exception as exc:  # noqa: BLE001
                log.exception("voice-turn failed")
                self._send(500, json.dumps({"ok": False, "error": f"{type(exc).__name__}: {exc}"},
                                           ensure_ascii=False).encode(), "application/json; charset=utf-8")
                return
            body = res["wav"]
            self.send_response(200 if body else 204)
            self.send_header("Content-Type", "audio/wav")
            self.send_header("Content-Length", str(len(body)))
            self.send_header("X-Heard", urllib.parse.quote(res.get("heard", "")))
            self.send_header("X-Reply", urllib.parse.quote(res.get("reply", "")))
            self.send_header("Cache-Control", "no-store")
            self.end_headers()
            if body:
                self.wfile.write(body)

        def _handle_stt(self) -> None:
            """Accept an uploaded audio blob (mic recording or file), run it
            through the real GPU STT, and return the recognised text."""
            if dash.stt is None:
                self._send(503, json.dumps({"ok": False, "error": "STT not enabled"}).encode(),
                           "application/json; charset=utf-8")
                return
            try:
                length = int(self.headers.get("Content-Length", "0"))
            except ValueError:
                length = 0
            if length <= 0:
                self._send(400, json.dumps({"ok": False, "error": "empty upload"}).encode(),
                           "application/json; charset=utf-8")
                return
            raw = self.rfile.read(length)
            try:
                result = dash.transcribe_upload(raw)
                body = json.dumps({"ok": True, **result}, ensure_ascii=False).encode("utf-8")
                self._send(200, body, "application/json; charset=utf-8")
            except Exception as exc:  # noqa: BLE001 — surface the reason to the page
                log.exception("STT upload failed")
                body = json.dumps({"ok": False, "error": f"{type(exc).__name__}: {exc}"},
                                  ensure_ascii=False).encode("utf-8")
                self._send(500, body, "application/json; charset=utf-8")

        def _default_persona(self) -> str:
            # The built-in seed prompt, used when no override is saved. Imported
            # lazily so the dashboard has no hard dependency on the Claude backend.
            try:
                from .backends.claude import ClaudeBrain
                return ClaudeBrain.PERSONA
            except Exception:
                return ""

        def _handle_prompt_get(self) -> None:
            """Return the live bot system prompt so the page can show/edit it."""
            from . import prompt_store
            default = self._default_persona()
            body = json.dumps({
                "ok": True,
                "prompt": prompt_store.get_persona(default),
                "default": default,
                "overridden": prompt_store.is_overridden(),
            }, ensure_ascii=False).encode("utf-8")
            self._send(200, body, "application/json; charset=utf-8")

        def _handle_prompt_post(self) -> None:
            """Save an edited system prompt; takes effect on the next reply. An
            empty prompt clears the override and reverts to the built-in default."""
            from . import prompt_store
            raw = self._read_body()
            try:
                data = json.loads(raw.decode("utf-8")) if raw else {}
                prompt = data.get("prompt", "")
            except (ValueError, AttributeError):
                self._send(400, json.dumps({"ok": False, "error": "invalid JSON"}).encode(),
                           "application/json; charset=utf-8")
                return
            prompt_store.set_persona(prompt)
            monitor.log("info", "봇 프롬프트가 수정되었습니다" if prompt.strip()
                        else "봇 프롬프트가 기본값으로 초기화되었습니다")
            body = json.dumps({
                "ok": True,
                "prompt": prompt_store.get_persona(self._default_persona()),
                "overridden": prompt_store.is_overridden(),
            }, ensure_ascii=False).encode("utf-8")
            self._send(200, body, "application/json; charset=utf-8")

        def _send_json(self, obj, code: int = 200) -> None:
            self._send(code, json.dumps(obj, ensure_ascii=False).encode("utf-8"),
                       "application/json; charset=utf-8")

        def _handle_bot_report(self) -> None:
            """The Discord bot pushes its live state (identity, guilds, voice
            channels, current channel + members, list settings)."""
            raw = self._read_body()
            try:
                data = json.loads(raw.decode("utf-8")) if raw else {}
            except (ValueError, AttributeError):
                self._send_json({"ok": False, "error": "invalid JSON"}, 400)
                return
            dash.bot.report(data)
            # Hand the bot any queued commands + the current listen filters in the
            # same round trip so it does not have to poll extra endpoints.
            self._send_json({"ok": True, "commands": dash.bot.drain(),
                             "lists": dash.bot.all_lists()})

        def _handle_bot_select(self) -> None:
            """UI picked a server/voice channel → queue a join (or leave) command."""
            raw = self._read_body()
            try:
                data = json.loads(raw.decode("utf-8")) if raw else {}
            except (ValueError, AttributeError):
                self._send_json({"ok": False, "error": "invalid JSON"}, 400)
                return
            guild_id = (data.get("guildId") or "").strip()
            channel_id = (data.get("channelId") or "").strip()
            if channel_id and guild_id:
                cid = dash.bot.enqueue({"type": "join", "guildId": guild_id, "channelId": channel_id})
                monitor.log("info", f"음성채널 참여 요청 (guild={guild_id} channel={channel_id})")
            else:
                cid = dash.bot.enqueue({"type": "leave"})
                monitor.log("info", "음성채널 나가기 요청")
            self._send_json({"ok": True, "commandId": cid})

        def _handle_bot_lists(self) -> None:
            """Save the whitelist/blacklist (users + roles) for a guild."""
            raw = self._read_body()
            try:
                data = json.loads(raw.decode("utf-8")) if raw else {}
                guild_id = (data.get("guildId") or "").strip()
                if not guild_id:
                    raise ValueError("guildId required")
            except (ValueError, AttributeError) as exc:
                self._send_json({"ok": False, "error": str(exc)}, 400)
                return
            saved = dash.bot.set_lists(guild_id, data.get("lists") or {})
            monitor.log("info", f"청취 화이트/블랙리스트 업데이트 (guild={guild_id})")
            self._send_json({"ok": True, "guildId": guild_id, "lists": saved})

        def _handle_log_mutate(self, action: str) -> None:
            """Per-line log delete/edit by event id."""
            raw = self._read_body()
            try:
                data = json.loads(raw.decode("utf-8")) if raw else {}
                event_id = int(data.get("id"))
            except (ValueError, TypeError, AttributeError):
                self._send(400, json.dumps({"ok": False, "error": "id required"}).encode(),
                           "application/json; charset=utf-8")
                return
            if action == "delete":
                ok = monitor.delete_event(event_id)
            else:
                ok = monitor.edit_event(event_id, str(data.get("message", "")))
            self._send(200 if ok else 404,
                       json.dumps({"ok": ok}).encode(), "application/json; charset=utf-8")

        def _stream_events(self) -> None:
            self.send_response(200)
            self.send_header("Content-Type", "text/event-stream; charset=utf-8")
            self.send_header("Cache-Control", "no-store")
            self.send_header("Connection", "keep-alive")
            self.end_headers()
            q = monitor.subscribe()
            try:
                # Prime the client with a full snapshot so it renders instantly.
                first = json.dumps(
                    {"type": "snapshot", "snapshot": monitor.snapshot()},
                    ensure_ascii=False,
                )
                self.wfile.write(f"data: {first}\n\n".encode("utf-8"))
                self.wfile.flush()
                while True:
                    try:
                        data = q.get(timeout=15)
                    except queue.Empty:
                        # Heartbeat keeps proxies / the browser from timing out.
                        self.wfile.write(b": ping\n\n")
                        self.wfile.flush()
                        continue
                    self.wfile.write(f"data: {data}\n\n".encode("utf-8"))
                    self.wfile.flush()
            except (BrokenPipeError, ConnectionResetError):
                pass
            finally:
                monitor.unsubscribe(q)

    return Handler


class Dashboard:
    """Owns the HTTP server thread.

    Optionally holds a real STT backend so the page can offer a live
    recognition test (upload/record audio -> GPU whisper -> text). The STT
    backend is async, so the dashboard runs its own asyncio loop in a
    background thread and bridges the synchronous HTTP handlers onto it.
    """

    def __init__(self, monitor: Monitor, host: str = "0.0.0.0", port: int = 8787,
                 stt=None, tts=None, brain=None, history_turns: int = 12) -> None:
        from .bot_control import BotControl
        self.monitor = monitor
        self.host = host
        self.port = port
        self.stt = stt
        self.tts = tts
        self.brain = brain
        self.bot = BotControl()  # dashboard <-> Discord bot control plane
        self._history: list[tuple[str, str]] = []
        self._history_turns = history_turns
        self._server: ThreadingHTTPServer | None = None
        self._thread: threading.Thread | None = None
        self._loop = None
        self._loop_thread: threading.Thread | None = None

    def start(self) -> None:
        if self.stt is not None or self.tts is not None:
            self._start_loop()
        handler = _make_handler(self)
        self._server = ThreadingHTTPServer((self.host, self.port), handler)
        self._server.daemon_threads = True
        self._thread = threading.Thread(
            target=self._server.serve_forever, name="wsai-dashboard", daemon=True
        )
        self._thread.start()
        log.info("dashboard on http://%s:%d", self.host, self.port)

    def stop(self) -> None:
        if self._server is not None:
            self._server.shutdown()
            self._server.server_close()
            self._server = None
        if self._loop is not None:
            self._loop.call_soon_threadsafe(self._loop.stop)
            self._loop = None

    # -- async bridge (STT test) ----------------------------------------- #
    def _start_loop(self) -> None:
        import asyncio

        self._loop = asyncio.new_event_loop()
        self._loop_thread = threading.Thread(
            target=self._loop.run_forever, name="wsai-dashboard-loop", daemon=True
        )
        self._loop_thread.start()

    def _submit(self, coro, timeout: float = 120.0):
        import asyncio

        fut = asyncio.run_coroutine_threadsafe(coro, self._loop)
        return fut.result(timeout=timeout)

    def warm(self) -> None:
        """Pre-start the STT/TTS workers (loads + warms the GPU) so the first
        recognition/synth is instant instead of paying model-load + CUDA autotune."""
        if self.stt is not None:
            self._submit(self.stt._ensure())
        if self.tts is not None:
            self._submit(self.tts._ensure())

    def voice_turn(self, audio_bytes: bytes, speaker: str = "",
                   guild: str = "", channel: str = "") -> dict:
        """One Discord voice turn: decode the uploaded utterance, recognise it
        on the GPU, think of a reply (Claude brain if wired, else echo),
        synthesise it on the GPU, and return {heard, reply, wav} where wav is the
        reply audio bytes for the bot to play back into the channel."""
        import os
        import subprocess
        import time
        import uuid

        if self.stt is None or self.tts is None:
            raise RuntimeError("voice_turn needs both STT and TTS")
        updir = os.path.expanduser("~/.cache/wsai/uploads")
        os.makedirs(updir, exist_ok=True)
        stem = os.path.join(updir, uuid.uuid4().hex)
        src, wav = stem + ".bin", stem + ".wav"
        with open(src, "wb") as f:
            f.write(audio_bytes)
        turn = self.monitor.turn(source="discord")
        if speaker:
            turn.speaker = speaker  # who spoke (for the "누가 말했는지" log)
        turn.guild, turn.channel = guild, channel  # for 서버별/채널별 필터
        t0 = time.monotonic()
        try:
            subprocess.run(
                ["ffmpeg", "-y", "-i", src, "-ar", "16000", "-ac", "1", wav],
                check=True, capture_output=True,
            )
            heard = (self._submit(self.stt.transcribe(wav)) or "").strip()
            turn.heard(heard or "(빈 결과)")
            if not heard:
                # Nothing recognised (silence/noise): mark it as [잡음] and skip
                # the brain/TTS so the bot plays nothing back.
                turn.thought("목소리가 아닌 잡음으로 판단 → 응답하지 않음")
                turn.replied("[잡음]")
                turn.finish()
                return {"heard": heard, "reply": "[잡음]", "wav": b""}
            reply_text = self._think(heard)
            turn.thought(_thought_summary(reply_text))
            turn.replied(reply_text)
            out_path = self._submit(self.tts.synth(_speech_text(reply_text)))
            with open(out_path, "rb") as f:
                reply_wav = f.read()
            ms = int((time.monotonic() - t0) * 1000)
            step = turn.step("STT+두뇌+TTS" if self.brain else "STT+TTS(GPU)")
            step.ok, step.ms = True, float(ms)
            turn._steps.append(step)
            turn.finish()
            try:
                os.remove(out_path)
            except OSError:
                pass
            return {"heard": heard, "reply": reply_text, "wav": reply_wav}
        except subprocess.CalledProcessError as exc:
            turn.finish(error="ffmpeg decode failed")
            err = exc.stderr.decode("utf-8", "replace")[-300:] if exc.stderr else str(exc)
            raise RuntimeError(f"ffmpeg: {err}") from exc
        except Exception as exc:
            turn.finish(error=str(exc))
            raise
        finally:
            for p in (src, wav):
                try:
                    os.remove(p)
                except OSError:
                    pass

    def _think(self, heard: str) -> str:
        """Turn what was heard into a reply. Uses the Claude brain when wired
        (with rolling conversation history); falls back to echo if there is no
        brain, and to a spoken apology if the brain call fails — so one API hiccup
        never kills the voice loop."""
        if self.brain is None:
            return heard  # echo mode
        try:
            reply = self._submit(self.brain.respond(heard, None, list(self._history)))
            text = (reply.text or "").strip()
            u = getattr(reply, "usage", None)
            if u:
                self.monitor.add_claude_usage(u.get("input", 0), u.get("output", 0))
        except Exception as exc:  # noqa: BLE001
            log.exception("brain failed")
            self.monitor.log("error", f"두뇌 응답 실패: {exc}")
            blob = f"{getattr(exc, 'status_code', '')} {exc}".lower()
            if "529" in blob or "overload" in blob:
                # Transient server overload survived the SDK retries.
                return "지금 서버가 잠깐 붐벼서 생각이 늦네. 잠시 뒤에 다시 말해줄래?"
            return "미안, 지금 잠깐 생각이 안 났어. 다시 말해줄래?"
        if not text:
            return "음, 뭐라고 해야 할지 모르겠어. 다시 말해줄래?"
        self._history.append((heard, text))
        if len(self._history) > self._history_turns:
            self._history = self._history[-self._history_turns:]
        return text

    def transcribe_upload(self, audio_bytes: bytes) -> dict:
        """ffmpeg-normalise an uploaded blob to 16 kHz mono wav, transcribe it
        on the GPU, and record the result as a monitor turn so it also shows in
        the live feed. Returns {text, ms, device}."""
        import os
        import subprocess
        import tempfile
        import time
        import uuid

        updir = os.path.expanduser("~/.cache/wsai/uploads")
        os.makedirs(updir, exist_ok=True)
        stem = os.path.join(updir, uuid.uuid4().hex)
        src, wav = stem + ".bin", stem + ".wav"
        with open(src, "wb") as f:
            f.write(audio_bytes)
        turn = self.monitor.turn(source="web")
        t0 = time.monotonic()
        try:
            # Decode whatever the browser sent (webm/opus, ogg, mp4, wav) to the
            # 16 kHz mono wav faster-whisper expects.
            subprocess.run(
                ["ffmpeg", "-y", "-i", src, "-ar", "16000", "-ac", "1", wav],
                check=True, capture_output=True,
            )
            text = self._submit(self.stt.transcribe(wav))
            ms = int((time.monotonic() - t0) * 1000)
            turn.heard(text or "(빈 결과)")
            step = turn.step("STT(GPU)")
            step.ok, step.ms = True, float(ms)
            turn._steps.append(step)
            turn.finish()
            return {"text": text, "ms": ms,
                    "device": getattr(self.stt, "resolved_device", None) or "?"}
        except subprocess.CalledProcessError as exc:
            turn.finish(error="ffmpeg decode failed")
            err = exc.stderr.decode("utf-8", "replace")[-300:] if exc.stderr else str(exc)
            raise RuntimeError(f"ffmpeg: {err}") from exc
        except Exception as exc:
            turn.finish(error=str(exc))
            raise
        finally:
            for p in (src, wav):
                try:
                    os.remove(p)
                except OSError:
                    pass


# --------------------------------------------------------------------------- #
# The page. One file, no external assets, so it works offline / behind a LAN.
# --------------------------------------------------------------------------- #
PAGE = r"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>watch_sceen_ai · 실시간 상태</title>
<style>
  :root{
    --bg:#0b0f14; --panel:#131a22; --panel2:#0f151c; --line:#223040;
    --fg:#e6edf3; --muted:#8aa0b2; --accent:#3fb6ff; --ok:#37d67a;
    --err:#ff5c6c; --warn:#ffc857; --heard:#7aa2ff; --reply:#b28bff;
  }
  *{box-sizing:border-box}
  body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Apple SD Gothic Neo","Malgun Gothic",sans-serif;
    background:var(--bg);color:var(--fg);line-height:1.5}
  header{position:sticky;top:0;z-index:5;background:linear-gradient(180deg,#0d141c,#0b0f14);
    border-bottom:1px solid var(--line);padding:14px 20px;display:flex;flex-wrap:wrap;gap:16px;align-items:center}
  h1{font-size:16px;margin:0;font-weight:650;letter-spacing:.2px}
  .sub{color:var(--muted);font-size:12px}
  .pill{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:999px;
    background:var(--panel);border:1px solid var(--line);font-size:12.5px;color:var(--muted)}
  .dot{width:9px;height:9px;border-radius:50%;background:#556}
  .dot.live{background:var(--ok);box-shadow:0 0 0 0 rgba(55,214,122,.6);animation:pulse 1.6s infinite}
  .dot.off{background:#556}
  @keyframes pulse{0%{box-shadow:0 0 0 0 rgba(55,214,122,.55)}70%{box-shadow:0 0 0 9px rgba(55,214,122,0)}100%{box-shadow:0 0 0 0 rgba(55,214,122,0)}}
  .stats{display:flex;gap:10px;flex-wrap:wrap;margin-left:auto}
  .stat{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:6px 12px;min-width:78px}
  .stat b{display:block;font-size:16px}
  .stat span{color:var(--muted);font-size:11px}
  main{max-width:1000px;margin:0 auto;padding:18px 20px 60px}
  .comp{display:flex;gap:8px;flex-wrap:wrap;margin:2px 0 18px}
  .comp .pill{font-size:11.5px}
  .turn{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px 16px;margin:12px 0;
    animation:rise .25s ease}
  @keyframes rise{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
  .turn.active{border-color:#2b5d86;box-shadow:0 0 0 1px #14324a inset}
  .turn.error{border-color:#5c2530}
  .trow{display:flex;align-items:baseline;gap:10px;margin-bottom:8px}
  .badge{font-size:11px;padding:2px 9px;border-radius:999px;border:1px solid var(--line);color:var(--muted);white-space:nowrap}
  .badge.ok{color:var(--ok);border-color:#1f5236}
  .badge.error{color:var(--err);border-color:#5c2530}
  .badge.active{color:var(--accent);border-color:#234a63}
  .time{color:var(--muted);font-size:11.5px;margin-left:auto}
  .line{display:flex;gap:9px;margin:5px 0;align-items:flex-start}
  .tag{flex:0 0 42px;font-size:11px;color:var(--muted);padding-top:2px}
  .heard{color:var(--heard);font-weight:550}
  .thought{color:var(--muted);font-size:13px}
  .reply{color:var(--reply);font-weight:550}
  .steps{margin-top:10px;border-top:1px dashed var(--line);padding-top:10px;display:flex;flex-direction:column;gap:6px}
  .step{display:grid;grid-template-columns:120px 1fr 66px;gap:10px;align-items:center;font-size:12.5px}
  .step .sname{color:var(--muted)}
  .step .sbar{height:8px;background:var(--panel2);border-radius:6px;overflow:hidden;border:1px solid var(--line)}
  .step .sfill{height:100%;background:linear-gradient(90deg,#2b7bb0,#3fb6ff)}
  .step.err .sfill{background:linear-gradient(90deg,#7a2531,#ff5c6c)}
  .step .sms{text-align:right;color:var(--fg);font-variant-numeric:tabular-nums}
  .step .serr{grid-column:1 / -1;color:var(--err);font-size:11.5px}
  .total{margin-top:8px;font-size:12px;color:var(--muted)}
  .total b{color:var(--fg)}
  .empty{color:var(--muted);text-align:center;padding:50px 0;font-size:14px}
  .events{margin-top:26px}
  .events h2{font-size:13px;color:var(--muted);font-weight:600;margin:0 0 8px}
  .ev{font-size:12px;color:var(--muted);padding:3px 0;border-bottom:1px solid #16202b;display:flex;gap:10px}
  .ev.error{color:var(--err)}
  .ev .et{flex:0 0 68px;color:#5f7488}
  code{background:#0c1219;padding:1px 5px;border-radius:5px;border:1px solid var(--line)}
  .demobar{background:#2a2210;border:1px solid #6b5417;color:var(--warn);border-radius:12px;
    padding:11px 15px;margin:0 0 16px;font-size:13px;line-height:1.55}
  .demobar b{color:#ffe08a}
  .sttbox{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px 16px;margin:0 0 16px}
  .sttbox h2{font-size:13px;margin:0 0 10px;font-weight:600;color:var(--fg)}
  .sttrow{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
  .btn{background:#173042;border:1px solid #234a63;color:var(--fg);border-radius:10px;padding:8px 14px;font-size:13px;cursor:pointer}
  .btn:hover{background:#1d3d54}
  .btn.rec{background:#4a1f27;border-color:#7a2531;color:#ffb3bb}
  .sttstat{color:var(--muted);font-size:12.5px}
  .sttres{margin-top:12px;font-size:15px;min-height:1px}
  .sttres .txt{color:var(--heard);font-weight:600;line-height:1.5}
  .sttres .meta{color:var(--muted);font-size:12px;margin-top:5px}
  .hbtn{padding:6px 12px;font-size:12.5px}
  /* Bot control bar (봇 정보 · 서버/채널 선택 · 참여자) */
  .botbar{display:flex;gap:14px;align-items:center;flex-wrap:wrap;background:var(--panel);
    border:1px solid var(--line);border-radius:12px;padding:10px 14px;margin:0 0 16px;font-size:13px}
  .botbar label{display:flex;gap:6px;align-items:center;color:var(--muted)}
  .botbar select{background:var(--panel2);border:1px solid var(--line);color:var(--fg);
    border-radius:8px;padding:6px 9px;font-size:13px;max-width:230px}
  .botinfo{display:flex;gap:7px;align-items:center;font-weight:600}
  .parts{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-left:auto;color:var(--muted)}
  .part{display:inline-flex;gap:5px;align-items:center;background:var(--panel2);border:1px solid var(--line);
    border-radius:999px;padding:3px 10px;font-size:12px}
  .part.spk{border-color:#1f5236;color:#9ff0bd}
  .speaker{color:var(--muted);font-size:11.5px}
  /* 대화 로그 검색 필터 (시간·유저·서버·채널·내용) */
  .tfilter{display:flex;gap:8px;align-items:center;flex-wrap:wrap;background:var(--panel);
    border:1px solid var(--line);border-radius:12px;padding:8px 12px;margin:0 0 12px}
  .tfilter input,.tfilter select{background:var(--panel2);border:1px solid var(--line);color:var(--fg);
    border-radius:8px;padding:6px 9px;font-size:12.5px}
  .tfilter input{width:118px}
  .tf-label{color:var(--muted);font-size:12px;font-weight:600}
  .tf-count{color:var(--muted);font-size:11.5px;margin-left:auto}
  /* Modal / popup (reused by 프롬프트, 화이트/블랙리스트 …) */
  .modal{position:fixed;inset:0;z-index:20;background:rgba(4,7,11,.66);
    display:flex;align-items:center;justify-content:center;padding:20px}
  .modal-card{background:var(--panel);border:1px solid var(--line);border-radius:16px;
    width:min(760px,100%);max-height:86vh;display:flex;flex-direction:column;overflow:hidden;
    box-shadow:0 24px 60px rgba(0,0,0,.5)}
  .modal-head{display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid var(--line)}
  .modal-title{font-size:14px;font-weight:650}
  .modal-actions{margin-left:auto;display:flex;gap:8px}
  .modal-body{padding:16px;overflow:auto}
  .modal-body textarea{width:100%;min-height:340px;background:var(--panel2);color:var(--fg);
    border:1px solid var(--line);border-radius:10px;padding:12px;font-size:13px;line-height:1.6;
    font-family:inherit;resize:vertical}
  .modal-body textarea[readonly]{color:var(--muted)}
  .modal-note{color:var(--muted);font-size:12px;margin:0 0 10px}
  .btn.primary{background:#16452c;border-color:#1f5236;color:#9ff0bd}
  .btn.primary:hover{background:#1b5636}
  .toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:30;
    background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px 16px;
    font-size:13px;box-shadow:0 10px 30px rgba(0,0,0,.4);opacity:0;transition:opacity .2s}
  .toast.show{opacity:1}
  /* 화이트/블랙리스트 팝업 */
  .lst-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:0 0 10px}
  .lst-row select,.lst-row input{background:var(--panel2);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:7px 10px;font-size:13px}
  .lst-search{flex:1;min-width:140px}
  .lst-results{max-height:210px;overflow:auto;border:1px solid var(--line);border-radius:10px;margin:0 0 12px}
  .lst-item{display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid #16202b;font-size:13px}
  .lst-item:last-child{border-bottom:none}
  .lst-item .nm{flex:1}
  .lst-item .rl{color:var(--muted);font-size:11px}
  .mini{padding:3px 8px;font-size:11.5px;border-radius:7px;cursor:pointer;border:1px solid var(--line);background:#173042;color:var(--fg)}
  .mini.w{border-color:#1f5236;color:#9ff0bd}
  .mini.b{border-color:#5c2530;color:#ffb3bb}
  .chips{display:flex;flex-wrap:wrap;gap:6px;margin:4px 0 12px}
  .chip{display:inline-flex;gap:6px;align-items:center;background:var(--panel2);border:1px solid var(--line);border-radius:999px;padding:3px 10px;font-size:12px}
  .chip.w{border-color:#1f5236}
  .chip.b{border-color:#5c2530}
  .chip button{background:none;border:none;color:var(--muted);cursor:pointer;padding:0}
  .lst-h{font-size:12px;color:var(--muted);margin:8px 0 4px;font-weight:600}
  /* Bottom-docked VSCode-style terminal log panel */
  main{padding-bottom:46px}
  .logdock{position:fixed;left:0;right:0;bottom:0;z-index:15;background:#0a0e13;
    border-top:1px solid var(--line);display:flex;flex-direction:column;
    max-height:45vh;box-shadow:0 -8px 24px rgba(0,0,0,.35)}
  .logbar{display:flex;align-items:center;gap:8px;padding:6px 12px;border-bottom:1px solid var(--line);
    background:#0d141c;flex-wrap:wrap}
  .logtoggle{background:none;border:none;color:var(--fg);font-size:12.5px;cursor:pointer;font-weight:600;padding:4px 6px}
  .logsearch{flex:1;min-width:120px;background:var(--panel2);border:1px solid var(--line);color:var(--fg);
    border-radius:8px;padding:5px 9px;font-size:12.5px}
  .logsel{background:var(--panel2);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:5px 8px;font-size:12.5px}
  .logcount{color:var(--muted);font-size:11.5px}
  .logbtn{padding:5px 10px;font-size:12px}
  .logbody{overflow:auto;padding:8px 12px;font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;
    font-size:12px;line-height:1.65;background:#0a0e13}
  .logdock.collapsed .logbody{display:none}
  .logdock.collapsed{max-height:none}
  .logline{display:flex;gap:8px;align-items:baseline;padding:1px 0;border-bottom:1px solid #10171f}
  .logline:hover{background:#0e151d}
  .logline .lt{flex:0 0 92px;color:#5f7488}
  .logline .lv{flex:0 0 46px;text-transform:uppercase;font-size:10.5px}
  .logline.info .lv{color:var(--accent)}
  .logline.error .lv{color:var(--err)}
  .logline.warn .lv{color:var(--warn)}
  .logline .lm{flex:1;color:var(--fg);white-space:pre-wrap;word-break:break-word}
  .logline.error .lm{color:#ffb3bb}
  .logline .lacts{opacity:0;display:flex;gap:4px}
  .logline:hover .lacts{opacity:1}
  .lact{background:none;border:none;color:var(--muted);cursor:pointer;font-size:12px;padding:0 3px}
  .lact:hover{color:var(--fg)}
</style>
</head>
<body>
<header>
  <div>
    <h1>watch_sceen_ai · 실시간 상태</h1>
    <div class="sub">STT → 두뇌 → TTS 음성 루프를 단계별로 관찰</div>
  </div>
  <div class="pill"><span id="dot" class="dot off"></span><span id="listen">연결 대기</span></div>
  <button id="promptBtn" class="btn hbtn">📝 프롬프트</button>
  <div class="stats">
    <div class="stat"><b id="s-turns">0</b><span>대화 수</span></div>
    <div class="stat"><b id="s-errors">0</b><span>오류</span></div>
    <div class="stat" title="오늘 사용한 클로드 토큰(입력+출력)"><b id="s-claude-today">0</b><span>오늘 토큰</span></div>
    <div class="stat" title="최근 7일 사용한 클로드 토큰(입력+출력)"><b id="s-claude-week">0</b><span>주간 토큰</span></div>
    <div class="stat"><b id="s-up">0초</b><span>가동시간</span></div>
    <div class="stat"><b id="s-conn">·</b><span>연결</span></div>
  </div>
</header>
<main>
  <section class="botbar" id="botbar">
    <span class="botinfo" id="botinfo"><span class="dot off"></span>봇: 연결 안 됨</span>
    <label>서버 <select id="guildSel"><option value="">없음</option></select></label>
    <label>음성채널 <select id="vcSel"><option value="">없음</option></select></label>
    <button id="wlBtn" class="btn hbtn">화이트리스트</button>
    <button id="blBtn" class="btn hbtn">블랙리스트</button>
    <span class="parts" id="parts"></span>
  </section>
  <section class="sttbox" id="sttbox" style="display:none">
    <h2>🎤 음성 인식(STT) 테스트 · GPU</h2>
    <div class="sttrow">
      <button id="recbtn" class="btn">🎤 녹음 시작</button>
      <label class="btn" for="fileinp">📁 오디오 파일 올리기</label>
      <input id="fileinp" type="file" accept="audio/*" hidden>
      <span id="ststat" class="sttstat">녹음하거나 오디오 파일을 올리면 GPU로 인식합니다.</span>
    </div>
    <div id="sttres" class="sttres"></div>
  </section>
  <div class="demobar" id="demobar" style="display:none"></div>
  <div class="comp" id="comp"></div>
  <div class="tfilter" id="tfilter">
    <span class="tf-label">대화 로그 검색</span>
    <select id="tfTime">
      <option value="0">전체 시간</option>
      <option value="5">최근 5분</option>
      <option value="30">최근 30분</option>
      <option value="60">최근 1시간</option>
      <option value="180">최근 3시간</option>
    </select>
    <input id="tfUser" placeholder="유저(발화자)">
    <input id="tfGuild" placeholder="서버">
    <input id="tfChannel" placeholder="채널">
    <input id="tfText" placeholder="내용(들음/답변)">
    <button id="tfClear" class="btn hbtn">초기화</button>
    <span id="tfCount" class="tf-count"></span>
  </div>
  <div id="turns"></div>
  <div id="empty" class="empty">아직 대화가 없습니다. 사용자가 말하면 여기에 단계별로 나타납니다.</div>
</main>
<div id="logdock" class="logdock">
  <div class="logbar">
    <button id="logToggle" class="logtoggle">▾ 이벤트 / 오류 로그</button>
    <input id="logSearch" class="logsearch" placeholder="로그 검색 (텍스트)">
    <select id="logLevel" class="logsel">
      <option value="">전체</option>
      <option value="error">오류만</option>
      <option value="warn">경고만</option>
      <option value="info">정보만</option>
    </select>
    <span id="logCount" class="logcount"></span>
    <button id="logClear" class="btn logbtn">로그 삭제</button>
  </div>
  <div id="logbody" class="logbody"></div>
</div>
<div id="modal" class="modal" style="display:none">
  <div class="modal-card">
    <div class="modal-head">
      <button class="btn back" id="modalBack">← 뒤로</button>
      <span class="modal-title" id="modalTitle"></span>
      <span class="modal-actions" id="modalActions"></span>
    </div>
    <div class="modal-body" id="modalBody"></div>
  </div>
</div>
<div id="toast" class="toast"></div>
<script>
const $ = (id)=>document.getElementById(id);
const turns = new Map();   // id -> turn object
let statusData = null;

function fmtTime(wall){
  const d = new Date(wall*1000);
  return d.toLocaleTimeString('ko-KR',{hour12:false}) +
    '.' + String(d.getMilliseconds()).padStart(3,'0');
}
function fmtNum(n){ n=n||0; if(n>=1e6) return (n/1e6).toFixed(2)+'M'; if(n>=1e3) return (n/1e3).toFixed(1)+'k'; return ''+n; }
function fmtUptime(s){
  s = Math.floor(s||0);
  const h=Math.floor(s/3600), m=Math.floor(s%3600/60), sec=s%60;
  if(h) return h+'시간 '+m+'분';
  if(m) return m+'분 '+sec+'초';
  return sec+'초';
}
function esc(t){const d=document.createElement('div');d.textContent=t==null?'':t;return d.innerHTML;}

function renderStatus(s){
  statusData = s;
  $('s-turns').textContent = s.turns_total ?? 0;
  $('s-errors').textContent = s.errors_total ?? 0;
  $('s-up').textContent = fmtUptime(s.uptime_s);
  const cu = s.claude_usage || {today:{input:0,output:0,requests:0}, week:{input:0,output:0,requests:0}};
  const td=cu.today||{}, wk=cu.week||{};
  const te=$('s-claude-today');
  if(te){ te.textContent = fmtNum((td.input||0)+(td.output||0));
    te.parentElement.title = '오늘 클로드 — 입력 '+(td.input||0).toLocaleString()+' · 출력 '+(td.output||0).toLocaleString()+' 토큰 · 요청 '+(td.requests||0)+'회'; }
  const we=$('s-claude-week');
  if(we){ we.textContent = fmtNum((wk.input||0)+(wk.output||0));
    we.parentElement.title = '최근 7일 클로드 — 입력 '+(wk.input||0).toLocaleString()+' · 출력 '+(wk.output||0).toLocaleString()+' 토큰 · 요청 '+(wk.requests||0)+'회'; }
  const listening = s.listening;
  $('dot').className = 'dot ' + (listening ? 'live' : 'off');
  $('listen').textContent = listening ? '듣는 중' : (s.running ? '실행 중 (대기)' : '중지됨');
  const comps = s.components || {};
  // Demo banner: if the ears/brain/mouth are still mock, everything below is
  // replayed sample data, not a real conversation. Say so loudly.
  const sttReal = comps.stt && comps.stt!=='mock' && comps.stt!=='none';
  $('sttbox').style.display = sttReal ? 'block' : 'none';
  const mockParts = ['stt','brain','tts'].filter(k => comps[k]==='mock');
  const bar = $('demobar');
  if(mockParts.length){
    bar.style.display='block';
    bar.innerHTML = '⚠ <b>데모 모드</b> — 실제 음성/STT/두뇌/TTS가 아직 연결되지 않아, 아래 대화는 '
      + '실제로 들은 내용이 아니라 <b>목(mock) 예시 스크립트</b>입니다. '
      + '실제 엔진(faster-whisper·Claude·MeloTTS)을 붙이면 이 자리에 진짜 발화·지연·오류가 표시됩니다.';
  } else {
    bar.style.display='none';
  }
  const el = $('comp'); el.innerHTML = '';
  const names = {source:'눈(소스)', vision:'시각', stt:'귀(STT)', brain:'두뇌', tts:'입(TTS)', text:'텍스트'};
  for(const k of Object.keys(names)){
    if(!(k in comps)) continue;
    const v = comps[k];
    const p = document.createElement('span');
    p.className = 'pill';
    p.innerHTML = '<span class="dot '+(v && v!=='none'?'live':'off')+'"></span>'+names[k]+': <b>&nbsp;'+esc(v||'off')+'</b>';
    el.appendChild(p);
  }
}
function setConn(ok){ $('s-conn').textContent = ok ? '●' : '○'; $('s-conn').style.color = ok ? 'var(--ok)':'var(--err)'; }

function maxMs(steps){ let m=1; for(const s of steps) m=Math.max(m, s.ms||0); return m; }

function turnEl(t){
  const wrap = document.createElement('div');
  wrap.className = 'turn ' + (t.status||'active');
  wrap.id = 'turn-'+t.id;
  const mx = maxMs(t.steps);
  let steps = '';
  for(const s of (t.steps||[])){
    const pct = Math.max(3, Math.round((s.ms||0)/mx*100));
    const err = s.ok===false;
    steps += '<div class="step'+(err?' err':'')+'">'
      + '<span class="sname">'+esc(s.name)+'</span>'
      + '<span class="sbar"><span class="sfill" style="width:'+pct+'%"></span></span>'
      + '<span class="sms">'+ (s.ms!=null? s.ms.toFixed(0)+' ms':'…') +'</span>'
      + (err && s.error ? '<span class="serr">⚠ '+esc(s.error)+'</span>':'')
      + '</div>';
  }
  const badge = t.status==='ok' ? '<span class="badge ok">정상</span>'
    : t.status==='error' ? '<span class="badge error">오류</span>'
    : '<span class="badge active">진행 중…</span>';
  wrap.innerHTML =
    '<div class="trow">'+badge
      +'<span class="badge">#'+t.id+' · '+esc(t.source||'voice')+'</span>'
      +(t.speaker?'<span class="badge">🗣 '+esc(t.speaker)+'</span>':'')
      +(t.channel?'<span class="badge">🔊 '+esc((t.guild?t.guild+' / ':'')+t.channel)+'</span>':'')
      +'<span class="time">'+fmtTime(t.wall)+'</span></div>'
    +'<div class="line"><span class="tag">들음</span><span class="heard">'+(t.heard?esc(t.heard):'<i style="color:var(--muted)">(수신 대기)</i>')+'</span></div>'
    +'<div class="line"><span class="tag">생각</span><span class="thought">'+(t.thought?esc(t.thought):'<i style="color:var(--muted)">…</i>')+'</span></div>'
    +'<div class="line"><span class="tag">답변</span><span class="reply">'+(t.reply?esc(t.reply):'<i style="color:var(--muted)">…생각 중</i>')+'</span></div>'
    +(t.error?'<div class="line"><span class="tag">오류</span><span style="color:var(--err)">'+esc(t.error)+'</span></div>':'')
    +'<div class="steps">'+steps+'</div>'
    +'<div class="total">총 소요 <b>'+(t.total_ms?t.total_ms.toFixed(0)+' ms':'…')+'</b></div>';
  return wrap;
}

function upsertTurn(t){
  turns.set(t.id, t);
  $('empty').style.display = 'none';
  const cont = $('turns');
  const existing = $('turn-'+t.id);
  const fresh = turnEl(t);
  if(existing){ existing.replaceWith(fresh); }
  else { cont.prepend(fresh); }
  applyTurnFilter();
}

// --- 대화 로그 검색: 시간·유저·서버·채널·내용 필터 ------------------------ #
function turnFilter(){
  return { mins:+$('tfTime').value, user:$('tfUser').value.trim().toLowerCase(),
    guild:$('tfGuild').value.trim().toLowerCase(), channel:$('tfChannel').value.trim().toLowerCase(),
    text:$('tfText').value.trim().toLowerCase() };
}
function turnMatches(t, f){
  if(f.mins && (Date.now()/1000 - (t.wall||0)) > f.mins*60) return false;
  if(f.user && !((t.speaker||'').toLowerCase().includes(f.user))) return false;
  if(f.guild && !((t.guild||'').toLowerCase().includes(f.guild))) return false;
  if(f.channel && !((t.channel||'').toLowerCase().includes(f.channel))) return false;
  if(f.text && !(((t.heard||'')+' '+(t.reply||'')+' '+(t.thought||'')).toLowerCase().includes(f.text))) return false;
  return true;
}
function applyTurnFilter(){
  const f=turnFilter(); let shown=0;
  for(const [id,t] of turns){ const el=$('turn-'+id); if(!el) continue;
    const ok=turnMatches(t,f); el.style.display=ok?'':'none'; if(ok) shown++; }
  const active = f.mins||f.user||f.guild||f.channel||f.text;
  $('tfCount').textContent = turns.size ? (active ? shown+' / '+turns.size+' 대화' : turns.size+' 대화') : '';
}

// --- Bottom terminal log panel: store all events, render filtered ---------- #
let logEvents = [];            // {id, level, message, wall}
function logMatches(e){
  const lv = $('logLevel').value;
  if(lv && e.level!==lv) return false;
  const q = $('logSearch').value.trim().toLowerCase();
  if(q && !((e.message||'').toLowerCase().includes(q) || fmtTime(e.wall).includes(q))) return false;
  return true;
}
function renderLogs(){
  const body = $('logbody');
  const shown = logEvents.filter(logMatches);
  body.innerHTML = shown.map(e =>
    '<div class="logline '+(e.level||'info')+'" data-id="'+e.id+'">'
    + '<span class="lt">'+fmtTime(e.wall)+'</span>'
    + '<span class="lv">'+esc(e.level||'info')+'</span>'
    + '<span class="lm">'+esc(e.message)+'</span>'
    + '<span class="lacts"><button class="lact" data-act="edit" title="수정">✎</button>'
    + '<button class="lact" data-act="del" title="삭제">✕</button></span>'
    + '</div>'
  ).join('');
  $('logCount').textContent = shown.length + (shown.length!==logEvents.length ? ' / '+logEvents.length : '') + '줄';
}
function addEvent(e){
  if(e.id==null){ e.id = 'c'+Date.now()+Math.random(); }
  logEvents.push(e);
  if(logEvents.length>2000) logEvents = logEvents.slice(-2000);
  renderLogs();
}

function applySnapshot(snap){
  renderStatus(snap.status);
  turns.clear(); $('turns').innerHTML='';
  const list = (snap.turns||[]);
  for(const t of list) upsertTurn(t);
  if(list.length===0){ $('empty').style.display='block'; }
  logEvents = (snap.events||[]).slice();
  renderLogs();
}

function connect(){
  const es = new EventSource('/events');
  es.onopen = ()=> setConn(true);
  es.onerror = ()=> setConn(false);
  es.onmessage = (m)=>{
    let ev; try{ ev = JSON.parse(m.data); }catch(_){ return; }
    if(ev.type==='snapshot') applySnapshot(ev.snapshot);
    else if(ev.type==='status') renderStatus(ev.status);
    else if(ev.type==='turn') upsertTurn(ev.turn);
    else if(ev.type==='log') { addEvent(ev); if(statusData){ statusData.errors_total=(statusData.errors_total||0)+(ev.level==='error'?1:0); $('s-errors').textContent=statusData.errors_total; } }
    else if(ev.type==='logs_cleared') { logEvents=[]; renderLogs(); }
    else if(ev.type==='log_deleted') { logEvents=logEvents.filter(e=>e.id!==ev.id); renderLogs(); }
    else if(ev.type==='log_edited') { const e=logEvents.find(e=>e.id===ev.id); if(e){e.message=ev.message; renderLogs();} }
  };
}
// --- STT recognition test (upload / mic record -> GPU whisper) ----------- #
let mediaRec=null, chunks=[];
async function sendBlob(blob){
  $('ststat').textContent='인식 중… (GPU)';
  $('sttres').innerHTML='';
  try{
    const r=await fetch('/api/stt',{method:'POST',
      headers:{'Content-Type':blob.type||'application/octet-stream'},body:blob});
    const j=await r.json();
    if(j.ok){
      $('sttres').innerHTML='<div class="txt">'+esc(j.text||'(빈 결과)')+'</div>'
        +'<div class="meta">인식 '+j.ms+' ms · '+esc(j.device)+'</div>';
      $('ststat').textContent='완료. 다시 녹음하거나 파일을 올릴 수 있습니다.';
    }else{
      $('sttres').innerHTML='<div class="meta" style="color:var(--err)">오류: '+esc(j.error)+'</div>';
      $('ststat').textContent='실패.';
    }
  }catch(e){ $('ststat').textContent='요청 실패: '+e; }
}
(function(){
  const fi=$('fileinp'); if(fi) fi.onchange=()=>{ if(fi.files[0]) sendBlob(fi.files[0]); };
  const rb=$('recbtn'); if(!rb) return;
  rb.onclick=async()=>{
    if(mediaRec && mediaRec.state==='recording'){ mediaRec.stop(); return; }
    if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){
      $('ststat').textContent='이 주소(원격 http)에서는 브라우저 마이크가 막혀 있습니다. 파일 업로드를 사용하세요.';
      return;
    }
    try{
      const stream=await navigator.mediaDevices.getUserMedia({audio:true});
      chunks=[]; mediaRec=new MediaRecorder(stream);
      mediaRec.ondataavailable=(e)=>{ if(e.data.size) chunks.push(e.data); };
      mediaRec.onstop=()=>{
        stream.getTracks().forEach(t=>t.stop());
        rb.textContent='🎤 녹음 시작'; rb.classList.remove('rec');
        sendBlob(new Blob(chunks,{type:mediaRec.mimeType||'audio/webm'}));
      };
      mediaRec.start();
      rb.textContent='⏹ 녹음 중지'; rb.classList.add('rec');
      $('ststat').textContent='녹음 중… 말한 뒤 중지를 누르세요.';
    }catch(e){ $('ststat').textContent='마이크 접근 실패: '+e; }
  };
})();

// --- Reusable popup/modal (프롬프트, 화이트/블랙리스트 등이 공유) ---------- #
function openModal(title, actionsHtml){
  $('modalTitle').textContent = title;
  $('modalActions').innerHTML = actionsHtml || '';
  $('modal').style.display = 'flex';
}
function closeModal(){ $('modal').style.display='none'; $('modalBody').innerHTML=''; $('modalActions').innerHTML=''; }
$('modalBack').onclick = closeModal;
$('modal').addEventListener('click', (e)=>{ if(e.target===$('modal')) closeModal(); });
document.addEventListener('keydown', (e)=>{ if(e.key==='Escape' && $('modal').style.display==='flex') closeModal(); });
let toastTimer=null;
function toast(msg){
  const t=$('toast'); t.textContent=msg; t.classList.add('show');
  clearTimeout(toastTimer); toastTimer=setTimeout(()=>t.classList.remove('show'), 2200);
}

// --- 프롬프트: 현재 시스템 프롬프트 보기/수정/저장 ------------------------- #
async function openPrompt(){
  openModal('봇 프롬프트', '<button class="btn" id="pEdit">수정</button>'
    + '<button class="btn primary" id="pSave" style="display:none">저장</button>');
  $('modalBody').innerHTML = '<p class="modal-note" id="pNote">현재 봇의 시스템 프롬프트입니다. 저장하면 다음 답변부터 즉시 적용됩니다. (빈칸 저장 시 기본값 복원)</p>'
    + '<textarea id="pText" readonly>불러오는 중…</textarea>';
  let data={};
  try{ data=await (await fetch('/api/prompt')).json(); }catch(e){ $('pText').value='불러오기 실패: '+e; return; }
  $('pText').value = data.prompt || '';
  $('pNote').textContent = (data.overridden ? '현재 사용자 지정 프롬프트가 적용 중입니다. ' : '현재 기본 프롬프트가 적용 중입니다. ')
    + '저장하면 다음 답변부터 즉시 적용됩니다. (빈칸 저장 시 기본값 복원)';
  $('pEdit').onclick = ()=>{ $('pText').removeAttribute('readonly'); $('pText').focus(); $('pEdit').style.display='none'; $('pSave').style.display='inline-block'; };
  $('pSave').onclick = async ()=>{
    $('pSave').disabled=true;
    try{
      const r=await fetch('/api/prompt',{method:'POST',headers:{'Content-Type':'application/json'},
        body:JSON.stringify({prompt:$('pText').value})});
      const j=await r.json();
      if(j.ok){ toast('프롬프트 저장됨 · 다음 답변부터 적용'); closeModal(); }
      else { toast('저장 실패: '+(j.error||'')); }
    }catch(e){ toast('저장 실패: '+e); }
    finally{ $('pSave').disabled=false; }
  };
}
$('promptBtn').onclick = openPrompt;

// --- 로그 패널: 열고닫기 / 검색 / 삭제(전체·개별) / 수정 -------------------- #
$('logToggle').onclick = ()=>{
  const d=$('logdock'); d.classList.toggle('collapsed');
  $('logToggle').textContent = (d.classList.contains('collapsed')?'▸':'▾') + ' 이벤트 / 오류 로그';
};
$('logSearch').oninput = renderLogs;
$('logLevel').onchange = renderLogs;
$('logClear').onclick = async ()=>{
  if(!confirm('로그를 모두 삭제할까요?')) return;
  try{ await fetch('/api/logs/clear',{method:'POST'}); toast('로그를 삭제했습니다'); }
  catch(e){ toast('삭제 실패: '+e); }
};
$('logbody').addEventListener('click', async (ev)=>{
  const btn = ev.target.closest('.lact'); if(!btn) return;
  const line = btn.closest('.logline'); const id = line && line.getAttribute('data-id');
  if(id==null) return;
  const act = btn.getAttribute('data-act');
  const isServer = !String(id).startsWith('c');   // server events have numeric ids
  if(act==='del'){
    if(isServer){ try{ await fetch('/api/logs/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:Number(id)})}); }catch(e){} }
    logEvents = logEvents.filter(e=>String(e.id)!==String(id)); renderLogs();
  } else if(act==='edit'){
    const cur = logEvents.find(e=>String(e.id)===String(id)); if(!cur) return;
    const nv = prompt('로그 수정', cur.message); if(nv==null) return;
    cur.message = nv; renderLogs();
    if(isServer){ try{ await fetch('/api/logs/edit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:Number(id),message:nv})}); }catch(e){} }
  }
});

// --- 봇 제어 바: 정보 표시 / 서버·채널 선택 / 참여자 --------------------- #
let botState = null;
let userPickedGuild = null;   // remember the user's server pick across polls
function renderBot(s){
  botState = s;
  const info=$('botinfo');
  if(s && s.connected){
    const id = s.identity||{};
    info.innerHTML = '<span class="dot live"></span>봇: <b>'+esc(id.tag||id.username||id.id||'연결됨')+'</b>';
  } else {
    info.innerHTML = '<span class="dot off"></span>봇: 연결 안 됨';
  }
  const guilds = (s&&s.guilds)||[];
  const cur = (s&&s.current)||{};
  const gsel=$('guildSel');
  const gpick = userPickedGuild!=null ? userPickedGuild : (cur.guildId||'');
  gsel.innerHTML = '<option value="">없음</option>' + guilds.map(g=>
    '<option value="'+esc(g.id)+'"'+(g.id===gpick?' selected':'')+'>'+esc(g.name)+'</option>').join('');
  // Voice channels of the picked guild.
  const g = guilds.find(g=>g.id===gpick);
  const vcs = (g&&g.voiceChannels)||[];
  const vsel=$('vcSel');
  vsel.innerHTML = '<option value="">없음</option>' + vcs.map(v=>
    '<option value="'+esc(v.id)+'"'+(v.id===cur.channelId?' selected':'')+'>'+esc(v.name)+'</option>').join('');
  // Participants in the current voice channel.
  const members=(s&&s.members)||[];
  $('parts').innerHTML = members.length
    ? '참여자: '+members.map(m=>'<span class="part'+(m.speaking?' spk':'')+'">'+(m.speaking?'🔊':'👤')+' '+esc(m.name||m.id)+'</span>').join('')
    : (s&&s.connected&&cur.channelId ? '참여자: (없음)' : '');
}
async function sendSelect(guildId, channelId){
  try{
    await fetch('/api/bot/select',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({guildId, channelId})});
    toast(channelId?'음성채널 참여 요청을 보냈습니다':'음성채널에서 나가기 요청을 보냈습니다');
  }catch(e){ toast('요청 실패: '+e); }
}
$('guildSel').onchange = ()=>{
  userPickedGuild = $('guildSel').value;
  renderBot(botState);   // 선택 서버의 음성채널 목록을 다시 채운다
  // 서버를 '없음'으로 두거나 다른 서버로 바꿔 음성채널이 선택되지 않은 상태면 나간다.
  if(!$('vcSel').value) sendSelect($('guildSel').value, '');
};
// 음성채널 변경 → 그 채널로 이동, '없음' → 나가기.
$('vcSel').onchange = ()=> sendSelect($('guildSel').value, $('vcSel').value);
// --- 화이트/블랙리스트 팝업 (유저·역할 검색 → 화이트/블랙 추가·제거) ------ #
const LKEY = {wu:'whitelistUsers', bu:'blacklistUsers', wr:'whitelistRoles', br:'blacklistRoles'};
async function openLists(){
  const guildId = $('guildSel').value || (botState&&botState.current&&botState.current.guildId) || '';
  if(!guildId){ toast('먼저 서버를 선택하세요'); return; }
  const g = ((botState&&botState.guilds)||[]).find(x=>x.id===guildId) || {members:[],roles:[]};
  let lists;
  try{ lists = (await (await fetch('/api/bot/lists?guildId='+encodeURIComponent(guildId))).json()).lists; }
  catch(e){ lists = {whitelistUsers:[],blacklistUsers:[],whitelistRoles:[],blacklistRoles:[]}; }
  openModal('청취 화이트/블랙리스트', '<button class="btn primary" id="lstSave">저장</button>');
  $('modalBody').innerHTML =
    '<p class="modal-note">화이트리스트에 넣으면 그 대상만 청취(비어있으면 전체 청취), 블랙리스트는 제외됩니다. 유저/역할별로 추가할 수 있어요.</p>'
    +'<div class="lst-row"><select id="lstType"><option value="user">유저</option><option value="role">역할</option></select>'
    +'<input id="lstSearch" class="lst-search" placeholder="이름으로 검색"></div>'
    +'<div class="lst-results" id="lstResults"></div>'
    +'<div class="lst-h">화이트리스트 (그 대상만 청취)</div><div class="chips" id="chipsW"></div>'
    +'<div class="lst-h">블랙리스트 (제외)</div><div class="chips" id="chipsB"></div>';
  const has=(arr,id)=>(arr||[]).some(x=>x.id===id);
  function add(kind,item){ const k=LKEY[kind]; if(!has(lists[k],item.id)) lists[k].push(item); renderChips(); }
  function rm(k,id){ lists[k]=(lists[k]||[]).filter(x=>x.id!==id); renderChips(); }
  function renderResults(){
    const type=$('lstType').value, q=$('lstSearch').value.trim().toLowerCase();
    const src = type==='user' ? (g.members||[]) : (g.roles||[]);
    const rows = src.filter(x=>!q || (x.name||'').toLowerCase().includes(q)).slice(0,100);
    $('lstResults').innerHTML = rows.length ? rows.map(x=>
      '<div class="lst-item"><span class="nm">'+esc(x.name)+(x.bot?' <span class="rl">(봇)</span>':'')+'</span>'
      +'<button class="mini w" data-k="'+(type==='user'?'wu':'wr')+'" data-id="'+esc(x.id)+'" data-nm="'+esc(x.name)+'">+화이트</button>'
      +'<button class="mini b" data-k="'+(type==='user'?'bu':'br')+'" data-id="'+esc(x.id)+'" data-nm="'+esc(x.name)+'">+블랙</button></div>'
    ).join('') : '<div class="lst-item"><span class="rl">결과 없음 · 봇이 아는 멤버/역할만 검색됩니다</span></div>';
  }
  const chip=(k,cls,x)=>'<span class="chip '+cls+'">'+esc(x.name)+' <button data-k="'+k+'" data-id="'+esc(x.id)+'">✕</button></span>';
  const empty='<span class="rl" style="color:var(--muted);font-size:12px">비어있음</span>';
  function renderChips(){
    $('chipsW').innerHTML = [...lists.whitelistUsers.map(x=>chip('whitelistUsers','w',x)),...lists.whitelistRoles.map(x=>chip('whitelistRoles','w',x))].join('') || (empty+' (전체 청취)');
    $('chipsB').innerHTML = [...lists.blacklistUsers.map(x=>chip('blacklistUsers','b',x)),...lists.blacklistRoles.map(x=>chip('blacklistRoles','b',x))].join('') || empty;
  }
  $('lstType').onchange=renderResults; $('lstSearch').oninput=renderResults;
  $('lstResults').onclick=(e)=>{ const b=e.target.closest('.mini'); if(!b)return; add(b.dataset.k,{id:b.dataset.id,name:b.dataset.nm}); };
  $('chipsW').onclick=$('chipsB').onclick=(e)=>{ const b=e.target.closest('button'); if(!b)return; rm(b.dataset.k,b.dataset.id); };
  $('lstSave').onclick=async()=>{
    try{ await fetch('/api/bot/lists',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({guildId,lists})}); toast('청취 필터 저장됨 · 봇에 곧 반영'); closeModal(); }
    catch(e){ toast('저장 실패: '+e); }
  };
  renderResults(); renderChips();
}
$('wlBtn').onclick = openLists; $('blBtn').onclick = openLists;

['tfTime','tfUser','tfGuild','tfChannel','tfText'].forEach(id=>{ const e=$(id); if(e){ e.oninput=applyTurnFilter; e.onchange=applyTurnFilter; } });
$('tfClear').onclick=()=>{ $('tfTime').value='0'; $('tfUser').value=''; $('tfGuild').value=''; $('tfChannel').value=''; $('tfText').value=''; applyTurnFilter(); };

async function pollBot(){
  try{ renderBot(await (await fetch('/api/bot/state')).json()); }catch(e){}
}
pollBot(); setInterval(pollBot, 2500);

connect();
// Refresh uptime label every second from the last known status.
setInterval(()=>{ if(statusData){ statusData.uptime_s=(statusData.uptime_s||0)+1; $('s-up').textContent=fmtUptime(statusData.uptime_s);} }, 1000);
</script>
</body>
</html>
"""
