#!/usr/bin/env python3
"""
땡구 스마트홈 관리 대시보드
- 서비스 시작/중지/재시작 버튼
- 웹서버 + 음성비서 실시간 로그
- 포트: 9090
"""

import http.server
import json
import os
import subprocess
import socketserver
import threading
import time
from collections import deque
from urllib.parse import urlparse, parse_qs

PORT = 9090
SERVICES = {
    "server": "ddanggu-server",
    "voice": "ddanggu-voice",
}

# ── 로그 버퍼 (최근 200줄) ────────────────────
_log_buffers = {
    "server": deque(maxlen=200),
    "voice": deque(maxlen=200),
}
_log_subscribers = {
    "server": [],
    "voice": [],
}
_log_lock = threading.Lock()


def _tail_journal(service_key):
    """journalctl -f 를 백그라운드로 실행, 로그를 버퍼에 저장"""
    unit = SERVICES[service_key]
    proc = subprocess.Popen(
        ["journalctl", "-u", unit, "-f", "-n", "100", "--no-pager", "-o", "short-iso"],
        stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
    )
    for line in proc.stdout:
        line = line.rstrip()
        with _log_lock:
            _log_buffers[service_key].append(line)
            for q in _log_subscribers[service_key]:
                q.append(line)


def _get_status(unit):
    try:
        r = subprocess.run(
            ["systemctl", "is-active", unit],
            capture_output=True, text=True, timeout=5
        )
        return r.stdout.strip()
    except Exception:
        return "unknown"


def _service_action(unit, action):
    try:
        r = subprocess.run(
            ["sudo", "systemctl", action, unit],
            capture_output=True, text=True, timeout=30
        )
        return r.returncode == 0, r.stderr.strip()
    except Exception as e:
        return False, str(e)


DASHBOARD_HTML = r"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>땡구 관리</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, 'Malgun Gothic', sans-serif; background: #0f0f0f; color: #e0e0e0; }

.header {
    background: #1a1a2e; padding: 16px 24px;
    display: flex; align-items: center; justify-content: space-between;
    border-bottom: 1px solid #333;
}
.header h1 { font-size: 20px; color: #fff; }
.header .links a { color: #6ea8fe; text-decoration: none; margin-left: 16px; font-size: 14px; }

.controls {
    display: flex; gap: 12px; padding: 16px 24px;
    flex-wrap: wrap; background: #161625; border-bottom: 1px solid #333;
}
.svc-group {
    display: flex; align-items: center; gap: 8px;
    background: #1e1e32; border-radius: 8px; padding: 10px 16px;
}
.svc-group .label { font-weight: 600; min-width: 70px; }
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 6px; }
.dot.active { background: #4caf50; box-shadow: 0 0 6px #4caf50; }
.dot.inactive { background: #f44336; }
.dot.unknown { background: #888; }

button {
    padding: 6px 14px; border: none; border-radius: 6px; cursor: pointer;
    font-size: 13px; font-weight: 500; transition: all .15s;
}
button:active { transform: scale(.95); }
.btn-start { background: #2e7d32; color: #fff; }
.btn-stop { background: #c62828; color: #fff; }
.btn-restart { background: #1565c0; color: #fff; }
.btn-start:hover { background: #388e3c; }
.btn-stop:hover { background: #e53935; }
.btn-restart:hover { background: #1976d2; }

.log-container {
    display: flex; gap: 0; height: calc(100vh - 140px);
}
.log-panel {
    flex: 1; display: flex; flex-direction: column;
    border-right: 1px solid #333;
}
.log-panel:last-child { border-right: none; }
.log-title {
    padding: 10px 16px; background: #1a1a2e; font-weight: 600;
    font-size: 14px; border-bottom: 1px solid #333;
    display: flex; justify-content: space-between; align-items: center;
}
.log-title .clear-btn {
    background: #333; color: #aaa; border: none; padding: 3px 10px;
    border-radius: 4px; cursor: pointer; font-size: 12px;
}
.log-body {
    flex: 1; overflow-y: auto; padding: 8px 12px;
    font-family: 'Cascadia Code', 'D2Coding', monospace; font-size: 12.5px;
    line-height: 1.6; background: #0a0a0a; white-space: pre-wrap; word-break: break-all;
}
.log-body .line { padding: 1px 0; }
.log-body .line:hover { background: #1a1a2a; }
.log-body .warn { color: #ffb74d; }
.log-body .error { color: #ef5350; }
.log-body .info { color: #81c784; }
.log-body .wake { color: #64b5f6; font-weight: bold; }

.toast {
    position: fixed; bottom: 24px; right: 24px;
    padding: 12px 20px; border-radius: 8px; font-size: 14px;
    background: #333; color: #fff; opacity: 0; transition: opacity .3s;
    z-index: 100;
}
.toast.show { opacity: 1; }

@media (max-width: 768px) {
    .log-container { flex-direction: column; height: calc(100vh - 200px); }
    .log-panel { border-right: none; border-bottom: 1px solid #333; }
    .controls { gap: 8px; }
}
</style>
</head>
<body>
<div class="header">
    <h1>땡구 관리 패널</h1>
    <div class="links">
        <a href="http://HA_URL_PLACEHOLDER" target="_blank">Home Assistant</a>
        <a href="http://SERVER_URL_PLACEHOLDER/smart-home-ha.html" target="_blank">스마트홈 UI</a>
        <a href="http://SERVER_URL_PLACEHOLDER/api/status" target="_blank">서버 API</a>
    </div>
</div>

<div class="controls">
    <div class="svc-group">
        <span class="label"><span class="dot unknown" id="dot-server"></span>웹서버</span>
        <button class="btn-start" onclick="action('server','start')">시작</button>
        <button class="btn-stop" onclick="action('server','stop')">중지</button>
        <button class="btn-restart" onclick="action('server','restart')">재시작</button>
    </div>
    <div class="svc-group">
        <span class="label"><span class="dot unknown" id="dot-voice"></span>음성비서</span>
        <button class="btn-start" onclick="action('voice','start')">시작</button>
        <button class="btn-stop" onclick="action('voice','stop')">중지</button>
        <button class="btn-restart" onclick="action('voice','restart')">재시작</button>
    </div>
    <div class="svc-group" style="margin-left:auto;">
        <button class="btn-restart" onclick="action('all','restart')">전체 재시작</button>
        <button class="btn-stop" onclick="action('all','stop')">전체 중지</button>
    </div>
</div>

<div class="log-container">
    <div class="log-panel">
        <div class="log-title">
            웹서버 로그 (ddanggu-server)
            <button class="clear-btn" onclick="clearLog('server')">지우기</button>
        </div>
        <div class="log-body" id="log-server"></div>
    </div>
    <div class="log-panel">
        <div class="log-title">
            음성비서 로그 (ddanggu-voice)
            <button class="clear-btn" onclick="clearLog('voice')">지우기</button>
        </div>
        <div class="log-body" id="log-voice"></div>
    </div>
</div>

<div class="toast" id="toast"></div>

<script>
const logEls = { server: document.getElementById('log-server'), voice: document.getElementById('log-voice') };
let autoScroll = { server: true, voice: true };

Object.keys(logEls).forEach(k => {
    logEls[k].addEventListener('scroll', () => {
        const el = logEls[k];
        autoScroll[k] = el.scrollTop + el.clientHeight >= el.scrollHeight - 30;
    });
});

function colorize(text) {
    if (/Wake Word 감지|Wake Word!/.test(text)) return 'wake';
    if (/\[ERROR\]|error|Error|Traceback|실패|FAILURE/.test(text)) return 'error';
    if (/\[WARN\]|warn|경고/.test(text)) return 'warn';
    if (/\[INFO\]|시작|완료|성공|로드/.test(text)) return 'info';
    return '';
}

function appendLog(key, text) {
    const el = logEls[key];
    const div = document.createElement('div');
    div.className = 'line ' + colorize(text);
    div.textContent = text;
    el.appendChild(div);
    while (el.children.length > 500) el.removeChild(el.firstChild);
    if (autoScroll[key]) el.scrollTop = el.scrollHeight;
}

function clearLog(key) {
    logEls[key].innerHTML = '';
}

function toast(msg, ok) {
    const t = document.getElementById('toast');
    t.textContent = msg;
    t.style.background = ok ? '#2e7d32' : '#c62828';
    t.classList.add('show');
    setTimeout(() => t.classList.remove('show'), 2500);
}

async function action(svc, act) {
    try {
        const r = await fetch('/api/control', {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify({service: svc, action: act})
        });
        const d = await r.json();
        toast(d.message, d.ok);
        setTimeout(refreshStatus, 1500);
    } catch (e) {
        toast('요청 실패: ' + e, false);
    }
}

async function refreshStatus() {
    try {
        const r = await fetch('/api/status');
        const d = await r.json();
        for (const [k, v] of Object.entries(d)) {
            const dot = document.getElementById('dot-' + k);
            if (!dot) continue;
            dot.className = 'dot ' + (v === 'active' ? 'active' : v === 'inactive' ? 'inactive' : 'unknown');
        }
    } catch(e) {}
}

function connectSSE(key) {
    const es = new EventSource('/api/logs/' + key);
    es.onmessage = e => appendLog(key, e.data);
    es.onerror = () => {
        es.close();
        setTimeout(() => connectSSE(key), 3000);
    };
}

connectSSE('server');
connectSSE('voice');
refreshStatus();
setInterval(refreshStatus, 5000);
</script>
</body>
</html>"""


class Handler(http.server.BaseHTTPRequestHandler):
    def log_message(self, format, *args):
        pass  # 콘솔 로그 억제

    def _json(self, data, code=200):
        body = json.dumps(data, ensure_ascii=False).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", len(body))
        self.end_headers()
        self.wfile.write(body)

    def _html(self, html):
        body = html.encode()
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", len(body))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        path = urlparse(self.path).path

        if path == "/":
            ha_url = os.environ.get("HA_URL", "http://192.168.75.97:8123").rstrip("/")
            srv_url = os.environ.get("SERVER_URL", "http://192.168.75.97:8080").rstrip("/")
            html = DASHBOARD_HTML.replace("HA_URL_PLACEHOLDER", ha_url.replace("http://", ""))
            html = html.replace("SERVER_URL_PLACEHOLDER", srv_url.replace("http://", ""))
            self._html(html)

        elif path == "/api/status":
            self._json({k: _get_status(v) for k, v in SERVICES.items()})

        elif path.startswith("/api/logs/"):
            key = path.split("/")[-1]
            if key not in _log_buffers:
                self._json({"error": "unknown service"}, 404)
                return
            self.send_response(200)
            self.send_header("Content-Type", "text/event-stream")
            self.send_header("Cache-Control", "no-cache")
            self.send_header("X-Accel-Buffering", "no")
            self.end_headers()

            # 기존 버퍼 전송
            with _log_lock:
                for line in _log_buffers[key]:
                    self.wfile.write(f"data: {line}\n\n".encode())
                self.wfile.flush()
                q = deque(maxlen=50)
                _log_subscribers[key].append(q)

            try:
                while True:
                    if q:
                        with _log_lock:
                            while q:
                                line = q.popleft()
                                self.wfile.write(f"data: {line}\n\n".encode())
                        self.wfile.flush()
                    else:
                        time.sleep(0.3)
            except (BrokenPipeError, ConnectionResetError, OSError):
                pass
            finally:
                with _log_lock:
                    try:
                        _log_subscribers[key].remove(q)
                    except ValueError:
                        pass
        else:
            self._json({"error": "not found"}, 404)

    def do_POST(self):
        path = urlparse(self.path).path
        if path == "/api/control":
            length = int(self.headers.get("Content-Length", 0))
            body = json.loads(self.rfile.read(length))
            svc = body.get("service", "")
            act = body.get("action", "")

            if act not in ("start", "stop", "restart"):
                self._json({"ok": False, "message": f"알 수 없는 액션: {act}"})
                return

            if svc == "all":
                results = []
                for key, unit in SERVICES.items():
                    ok, err = _service_action(unit, act)
                    results.append(f"{key}: {'성공' if ok else err}")
                self._json({"ok": True, "message": f"전체 {act}: {', '.join(results)}"})
            elif svc in SERVICES:
                ok, err = _service_action(SERVICES[svc], act)
                label = {"server": "웹서버", "voice": "음성비서"}[svc]
                msg = f"{label} {act} 성공" if ok else f"{label} {act} 실패: {err}"
                self._json({"ok": ok, "message": msg})
            else:
                self._json({"ok": False, "message": f"알 수 없는 서비스: {svc}"})
        else:
            self._json({"error": "not found"}, 404)


def main():
    # settings.env 로드
    env_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "settings.env")
    if os.path.exists(env_file):
        with open(env_file, encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if line and not line.startswith("#") and "=" in line:
                    k, v = line.split("=", 1)
                    os.environ.setdefault(k.strip(), v.strip())

    # 로그 스트림 시작
    for key in SERVICES:
        t = threading.Thread(target=_tail_journal, args=(key,), daemon=True)
        t.start()

    server = socketserver.ThreadingTCPServer(("", PORT), Handler)
    server.daemon_threads = True
    rpi_ip = os.environ.get("RPI_IP", "0.0.0.0")
    print(f"땡구 관리 패널 → http://{rpi_ip}:{PORT}")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n관리 패널 종료")


if __name__ == "__main__":
    main()
