"""OPPA V12 Windows widget — 浮動小視窗版（不再 tray-only）。

設計：
  - 收合 (collapsed): 60x60 OPPA favicon 方形，右上角狀態點（灰=離線/綠=連線/
                       琥珀=錄音…），always-on-top 飄在桌面。
  - 展開 (expanded):  ~520x420，2x2 SOAP 四格 (S/O/A/P)，
                       每格內容是 server 最新 SOAP 對應段落。
                       點單一格 → 該段落 clipboard.copy
                       「整段複製」按鈕 → 整段 SOAP clipboard.copy
                       (醫師自行在 HIS 按 Ctrl+V;不再自動貼、不用 F9/keyboard hook)。
  - 可拖曳，右上角 [×] 收合到圓圈。

拓樸：USB-RJ45 point-to-point 連 OPPA box，啟動 parallel scan
192.168.99.1-99:8766 找到 OPPA 自動連 WS。

依賴 (pip install)：
  websocket-client  pyperclip
  (tkinter 是 Python stdlib,不用裝;已移除 keyboard hook → 降低防毒誤報)
"""
from __future__ import annotations
import argparse
import base64
import configparser
import csv
import hashlib
import json
import os
import re
import socket
import sys
import threading
import time
import tkinter as tk
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
from tkinter import filedialog, messagebox, simpledialog
from tkinter import font as tkfont
from tkinter import ttk

import pyperclip
import websocket

# digest「今日文獻快報」內文的 PMID 標記 → 轉可點 PubMed 連結
PMID_RE = re.compile(r"\[PMID\s+(\d{6,9})\]")


def _open_url(url):
    """開預設瀏覽器看連結。**不用 webbrowser**（onedir 凍結未打包該模組，自更新後
    import 會炸）→ 只用已打包的 os/subprocess。Windows 走 os.startfile，其餘 fallback。"""
    try:
        os.startfile(url)          # Windows：已打包，最穩
        return
    except AttributeError:
        pass                       # 非 Windows：os 無 startfile
    except Exception:
        return                     # Windows 但開啟失敗 → 靜默放棄
    for opener in ("xdg-open", "open"):
        try:
            import subprocess       # 包在 try 內：未打包也不丟到呼叫端
            subprocess.Popen([opener, url])
            return
        except Exception:
            continue


# 離線緊急解鎖碼（v11 遺留相容，勿刪）— 實為 canary 誘餌：正常流程從不送出，
# 反編譯者若抽出拿去試 activate/login，Liaison 即告警「有人在破解」。勿在別處引用。
OPPA_EMERGENCY_UNLOCK = "OPPA-OVERRIDE-9F3K7Q"

CONFIG_PATH = Path.home() / ".oppa-widget.ini"
LICENSE_CACHE_PATH = Path.home() / ".oppa-license.json"
TOKEN_PATH = Path.home() / ".oppa-device.tok"   # DPAPI 加密的裝置 token（綁 Windows）

# Liaison 控制通道：全診所統一 → 寫死常數（.ini liaison_ws_url 可覆寫，測試/staging 用）。
LIAISON_URL = "wss://service.liaison.tw/agent"

# ── widget 自我更新（Phase 2）──────────────────────────────────────
# 連 Liaison → 比 sha → 下載新檔(簽章)→ 驗 + py_compile → 換檔 → 自我重啟。
# 兩種可更新部署：
#   (1) 純原始碼 `python widget.py`（非 frozen）→ WIDGET_FILE = 本檔。
#   (2) frozen 啟動器 oppa_launcher.exe + 外部鬆散 widget.py（OPPA_WIDGET_FILE 指它）
#       → 只換那個鬆散 .py、重啟啟動器 exe，不動被鎖的 .exe。
# 舊式 onefile（widget.py 直接打包進 exe）→ 無外部檔 → 不自更新（WIDGET_FILE="")。
_IS_FROZEN = getattr(sys, "frozen", False)
_EXT_WIDGET = os.environ.get("OPPA_WIDGET_FILE", "").strip()
if _EXT_WIDGET:
    WIDGET_FILE = _EXT_WIDGET                          # 啟動器模式：外部可更新檔
elif not _IS_FROZEN:
    WIDGET_FILE = os.path.abspath(__file__)            # 純原始碼
else:
    WIDGET_FILE = ""                                   # 舊 onefile → 不自更新
# 內嵌 OTA 公鑰（與 server/oppa_update.py 同一把；簽章 ed25519 選配驗證）
_OTA_PUB_PEM = b"""-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAlls+V9/tv4Ae7wJt6ZL0X9tjyDYSnq0bIdW+Qvfbl5s=
-----END PUBLIC KEY-----"""


def _widget_self_sha() -> str:
    if not WIDGET_FILE:
        return ""
    try:
        return hashlib.sha256(Path(WIDGET_FILE).read_bytes()).hexdigest()
    except Exception:
        return ""


# ── 裝置 token at-rest 加密：Windows DPAPI（綁機器+帳號）──────────────
def _dpapi_protect(data: bytes) -> bytes:
    """Windows: CryptProtectData(CurrentUser)。非 Windows: dev fallback（僅開發,不安全）。"""
    if sys.platform.startswith("win"):
        import ctypes
        from ctypes import wintypes
        class BLOB(ctypes.Structure):
            _fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))]
        inp = BLOB(len(data), ctypes.cast(ctypes.create_string_buffer(data, len(data)),
                                          ctypes.POINTER(ctypes.c_char)))
        out = BLOB()
        if not ctypes.windll.crypt32.CryptProtectData(ctypes.byref(inp), None, None,
                                                      None, None, 0, ctypes.byref(out)):
            raise OSError("CryptProtectData failed")
        res = ctypes.string_at(out.pbData, out.cbData)
        ctypes.windll.kernel32.LocalFree(out.pbData)
        return res
    return b"DEVFALLBACK:" + base64.b64encode(data)   # 非 Windows 開發用


def _dpapi_unprotect(blob: bytes) -> bytes:
    if sys.platform.startswith("win"):
        import ctypes
        from ctypes import wintypes
        class BLOB(ctypes.Structure):
            _fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))]
        inp = BLOB(len(blob), ctypes.cast(ctypes.create_string_buffer(blob, len(blob)),
                                          ctypes.POINTER(ctypes.c_char)))
        out = BLOB()
        if not ctypes.windll.crypt32.CryptUnprotectData(ctypes.byref(inp), None, None,
                                                        None, None, 0, ctypes.byref(out)):
            raise OSError("CryptUnprotectData failed")
        res = ctypes.string_at(out.pbData, out.cbData)
        ctypes.windll.kernel32.LocalFree(out.pbData)
        return res
    if blob.startswith(b"DEVFALLBACK:"):
        return base64.b64decode(blob[len(b"DEVFALLBACK:"):])
    raise ValueError("bad blob")


def save_device_token(token: str):
    TOKEN_PATH.write_bytes(_dpapi_protect(token.encode()))
    try:
        TOKEN_PATH.chmod(0o600)
    except Exception:
        pass


def load_device_token() -> str:
    try:
        return _dpapi_unprotect(TOKEN_PATH.read_bytes()).decode()
    except Exception:
        return ""

# 落地加密匯出：解密用（crypto_envelope.py 須隨 exe 打包；未佈署加密前可缺）
try:
    import crypto_envelope as _ce
except Exception:
    _ce = None


def _decrypt_export_rows(rows: list, liaison) -> tuple:
    """把加密匯出的 rows（含 enc envelope、PHI 文字空）經 Liaison KMS 授權解回明文。

    回 (ok, reason)。ok=True 時就地把 S/O/A/P/transcript/references 填回 rows。
    流程：收集各筆 enc → kms_unwrap(授權閘) → 逐筆用回傳 DEK 本地解 → 合併。
    授權失敗/離線/逾時 → (False, reason)，呼叫端據此提示（不續約=拿不到歷史明文）。"""
    enc_idx = [i for i, r in enumerate(rows) if r.get("enc")]
    if not enc_idx:
        return True, None
    if _ce is None:
        return False, "no_crypto"        # exe 未含 crypto_envelope
    items = [{"epk": rows[i]["enc"]["epk"], "wdek": rows[i]["enc"]["wdek"]} for i in enc_idx]
    res = liaison.kms_unwrap(items) if liaison else {"ok": False, "reason": "no_liaison"}
    if not res.get("ok"):
        return False, res.get("reason", "denied")
    deks = res.get("deks") or []
    if len(deks) != len(enc_idx):
        return False, "dek_count_mismatch"
    for n, i in enumerate(enc_idx):
        try:
            dek = base64.b64decode(deks[n])
            phi = _ce.decrypt_json(rows[i]["enc"], dek)
            for k in ("S", "O", "A", "P"):
                rows[i][k] = phi.get(k, "")
            rows[i]["transcript"] = phi.get("transcript", "")
            rows[i]["references"] = phi.get("references_text", "")
            rows[i].pop("enc", None)
        except Exception as e:
            return False, f"decrypt_error:{e}"
    return True, None


def _crlf(s: str) -> str:
    """換行正規化成 CRLF。展望等 Windows HIS 文字欄位只認 \\r\\n，
    收到單純 \\n 會把分行吃掉、整段黏成一行。先收斂任何 CR/CRLF 再展開,冪等。"""
    return (s or "").replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\r\n")


_PROV_TAG_RE = re.compile(r"[ \t]*\[(?:[A-Za-z]+(?::\d+)?|\?)\][ \t]*$")


def _strip_prov_tags(s: str) -> str:
    """去掉每行尾端的 provenance 標 [T]/[I]/[KB]/[ref:N]/[?]。
    server 主顯示(clean_soap/fields)已 strip;但下拉選『舊』筆「整段複製」拿的是
    soap_raw(原始帶標)→ 這裡補strip,確保貼進 HIS 永遠乾淨。冪等(無標不變)。"""
    if not s:
        return s
    return "\n".join(_PROV_TAG_RE.sub("", ln) for ln in s.split("\n"))

STATE_COLOR = {
    "offline":      "#787878",
    "idle":         "#3fb950",
    "recording":    "#e6a230",
    "transcribing": "#3c82e6",
    "generating":   "#3c82e6",
    "error":        "#d23c3c",
}
STATE_LABEL = {
    "offline":      "離線",
    "idle":         "待機",
    "recording":    "錄音中",
    "transcribing": "辨識中",
    "generating":   "生成中",
    "error":        "錯誤",
}

# 維修隧道狀態 → 圖示左上角小點顏色 / 選單文字
MAINT_COLOR = {
    "up":           "#a371f7",  # 紫 = 通
    "connecting":   "#e6a230",  # 琥珀 = 連線中
    "down":         "#d23c3c",  # 紅 = 斷線重連中
    "disabled":     "#3a3a3a",  # 暗 = 停用
    "unconfigured": "#3a3a3a",  # 暗 = 未設定 liaison_host
}
MAINT_LABEL = {
    "up":           "通",
    "connecting":   "連線中…",
    "down":         "斷線重連中",
    "disabled":     "停用",
    "unconfigured": "未設定",
}

# 授權預設全開：任何非授權狀態一律當「已授權」綠燈、清掉 grace 提醒、地端 AI 權益開通。
# 只放寬顯示/軟性限制，不改連線與憑證流程。設 OPPA_LICENSE_OPEN=0 恢復嚴格。
LICENSE_OPEN = os.environ.get("OPPA_LICENSE_OPEN", "1").strip().lower() \
    not in ("0", "false", "no", "off")

# 授權(續費)狀態 → 圖示右下角小點顏色 / 選單文字。fail-soft：任何狀態都不擋臨床。
LICENSE_COLOR = {
    "licensed":    "#3fb950",  # 綠 = 已授權(線上)
    "offline_ok":  "#3fb950",  # 綠 = 已授權(離線快取仍在效期)
    "grace":       "#e6a230",  # 琥珀 = 過期但在離線寬限
    "expired":     "#e6a230",  # 琥珀 = 線上查到已到期(僅示警)
    "stale":       "#d23c3c",  # 紅 = 超過寬限(仍不硬擋)
    "denied":      "#d23c3c",  # 紅 = 被拒(停權/密鑰錯)
    "unknown":     "#787878",  # 灰 = 無快取且離線
    "checking":    "#3c82e6",  # 藍 = 連線檢查中
    "disabled":    "#3a3a3a",  # 暗 = 未設定
    "needs_activation": "#3c82e6",  # 藍 = 待輸入啟用碼
}
LICENSE_LABEL = {
    "licensed":   "已授權",
    "offline_ok": "已授權(離線)",
    "grace":      "寬限期",
    "expired":    "已到期",
    "stale":      "逾寬限",
    "denied":     "未授權",
    "unknown":    "未知(離線)",
    "checking":   "檢查中…",
    "disabled":   "未設定",
    "needs_activation": "待啟用",
}


# ── Config ──────────────────────────────────────────────────────────────
def parse_args():
    p = argparse.ArgumentParser(description="OPPA V12 Windows widget")
    p.add_argument("--server", help="跳過掃描，直連這個 IP")
    p.add_argument("--port", type=int, default=None)
    p.add_argument("--hotkey", default=None)
    p.add_argument("--subnet", default=None)
    p.add_argument("--scan-timeout", type=float, default=None)
    return p.parse_args()


def read_config() -> dict:
    if not CONFIG_PATH.exists():
        return {}
    cp = configparser.ConfigParser()
    cp.read(CONFIG_PATH, encoding="utf-8")
    return dict(cp["oppa"]) if "oppa" in cp else {}


def write_config_template():
    cp = configparser.ConfigParser()
    cp["oppa"] = {
        "server_ip":    "",
        "port":         "8766",
        "hotkey":       "f9",
        "subnet":       "192.168.99",
        "scan_timeout": "0.5",
        # ── 維修隧道（預設關閉；填好 liaison_host 並設 maint_enabled=1 才生效）──
        "maint_enabled": "0",
        "liaison_host":  "",          # Liaison 公網端點：公網IP 或 DDNS 域名
        "liaison_port":  "22",        # Liaison sshd 對外 port
        "tunnel_user":   "tunnel",    # Liaison 上只准轉埠的受限帳號
        "ssh_key":       "",          # 診所電腦上私鑰路徑，例 C:\\Users\\xx\\.ssh\\oppa_tunnel
        "relay_bind":    "127.0.0.1", # Liaison 上綁哪：127.0.0.1=只能從 Liaison 本機跳(較安全)
        "relay_port":    "2299",      # Liaison 上開的轉發 port（你公司端連這個）
        "oppa_ssh_host": "192.168.99.99",  # 隧道唯一能到的目的地（白名單）
        "oppa_ssh_port": "22",
        # ── Liaison 登入/續費（空 liaison_ws_url 或 clinic_id → 不啟用，純 no-op）──
        "liaison_ws_url":     "",          # 例 wss://liaison.tw/agent
        "clinic_id":          "",          # 這家診所代碼，例 clinic-a
        "clinic_secret":      "",          # licenses.py add 印出的一次性密鑰
        "license_grace_days": "14",        # server 沒給時的離線寬限天數 fallback
        # 維修串流(stage2)：經 Liaison 控制通道 pipe 到 OPPA SSH（白名單，寫死）
        "maint_stream_enabled": "1",       # 0 = 拒絕任何維修串流
        # oppa_ssh_host / oppa_ssh_port 沿用上面維修隧道那組（同一台 OPPA）
    }
    with open(CONFIG_PATH, "w", encoding="utf-8") as f:
        f.write("# server_ip 空 = startup scan subnet.1-99 找 OPPA\n")
        f.write("# 寫死 IP = 跳過 scan 直連\n")
        f.write("#\n")
        f.write("# 維修隧道：widget 從診所端 ssh -R 連到 Liaison，開一條『只通到\n")
        f.write("# oppa_ssh_host:oppa_ssh_port』的縫。你在公司登進 Liaison 後:\n")
        f.write("#   ssh -p <relay_port> oppa@127.0.0.1\n")
        f.write("# 需 Liaison 公網可達 + Liaison 上受限 tunnel 帳號 + 診所端私鑰。\n")
        f.write("#\n")
        f.write("# Liaison 登入/續費：widget 開機連 liaison_ws_url(wss://liaison.tw/agent)\n")
        f.write("# 送 clinic_id+clinic_secret 查訂閱。fail-soft：連不到用快取+寬限,不擋看診。\n")
        cp.write(f)


# ── Scan ────────────────────────────────────────────────────────────────
def scan_oppa(subnet: str, port: int, timeout: float) -> str | None:
    ips = [f"{subnet}.{i}" for i in range(1, 100)]

    def probe(ip):
        try:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
                s.settimeout(timeout)
                if s.connect_ex((ip, port)) == 0:
                    return ip
        except Exception:
            pass
        return None

    with ThreadPoolExecutor(max_workers=99) as ex:
        futs = {ex.submit(probe, ip): ip for ip in ips}
        for fut in as_completed(futs):
            r = fut.result()
            if r:
                return r
    return None


# ── SOAP parser ─────────────────────────────────────────────────────────
_HEADER_RE = re.compile(
    r"^(S|O|A|P|primary_dx|secondary_dxs|extracted_facts|References):\s*",
    re.M,
)


def parse_soap(text: str) -> dict:
    out = {"S": "", "O": "", "A": "", "P": ""}
    if not text:
        return out
    matches = list(_HEADER_RE.finditer(text))
    for i, m in enumerate(matches):
        head = m.group(1)
        if head not in ("S", "O", "A", "P"):
            continue
        start = m.end()
        end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
        # 去 leading newline + dedent indented lines
        body = text[start:end].strip("\n").rstrip()
        # 移除每行開頭多餘的縮排（保留結構）
        lines = [ln.rstrip() for ln in body.split("\n")]
        if lines and not lines[0].lstrip():
            lines = lines[1:]
        # de-indent common prefix
        non_empty = [ln for ln in lines if ln.strip()]
        if non_empty:
            min_indent = min(len(ln) - len(ln.lstrip()) for ln in non_empty)
            lines = [ln[min_indent:] if len(ln) >= min_indent else ln
                     for ln in lines]
        out[head] = "\n".join(lines).strip()
    return out


# ── 維修隧道：widget 當『只開一條縫』的閘口 ──────────────────────────────
class MaintTunnel:
    """把診所電腦變成一道只開一條縫的防火牆。

    widget 從診所端『主動撥出』(outbound，過得了 NAT) ssh -R 連到 Liaison，
    在 Liaison 上開一個 relay_port，而這條通道『唯一』能到的目的地就是
    oppa_ssh_host:oppa_ssh_port (預設 192.168.99.99:22)。-R 的目標寫死 = 強制
    白名單；LAN 上其他主機/其他 port 一律穿不過去。widget 開著就維持隧道、
    斷線自動重連(backoff)；widget 關掉就收線。

    你在公司端：登進 Liaison → `ssh -p <relay_port> oppa@127.0.0.1` 進 OPPA。
    OPPA 全程離線、只在 eno1 LAN 被動收 SSH。

    前提：
      - Liaison 要有『公網可達』端點(liaison_host:liaison_port)——公網 IP，或
        DDNS + 路由器 port forward。診所端是 outbound，所以 Liaison 那頭得能被連到。
      - Liaison 上一個只准轉埠、沒有 shell 的帳號：
        authorized_keys 該行前綴 `restrict,port-forwarding`。
      - 診所電腦上一把對應私鑰(ssh_key)；遺失就在 Liaison 撤這把鑰。
      - Windows 內建 OpenSSH client (ssh.exe，Win10+ 預設有)。
    """
    def __init__(self, cfg: dict, on_status=None):
        self.cfg = cfg or {}
        self.on_status = on_status or (lambda s: None)
        self.enabled = str(self.cfg.get("maint_enabled", "0")).strip().lower() \
            in ("1", "true", "yes", "on")
        self._stop = threading.Event()
        self._proc = None
        self._thread = None
        self.status = "disabled"

    def _build_cmd(self):
        c = self.cfg
        bind = (c.get("relay_bind") or "127.0.0.1").strip()
        fwd = (f"{bind}:{c.get('relay_port', '2299')}:"
               f"{c.get('oppa_ssh_host', '192.168.99.99')}:"
               f"{c.get('oppa_ssh_port', '22')}")
        cmd = ["ssh", "-N", "-T",
               "-o", "ExitOnForwardFailure=yes",  # Liaison port 綁不上就退→重連
               "-o", "ServerAliveInterval=30",
               "-o", "ServerAliveCountMax=3",
               "-o", "StrictHostKeyChecking=accept-new",
               "-o", "BatchMode=yes",             # 不互動、不問密碼(純金鑰)
               "-R", fwd,
               "-p", str(c.get("liaison_port", "22"))]
        key = (c.get("ssh_key") or "").strip()
        if key:
            cmd += ["-i", key]
        cmd.append(f"{c.get('tunnel_user', 'tunnel')}@{c.get('liaison_host', '')}")
        return cmd

    def _set(self, status: str):
        self.status = status
        try:
            self.on_status(status)
        except Exception:
            pass

    def _kill(self):
        try:
            if self._proc and self._proc.poll() is None:
                self._proc.terminate()
        except Exception:
            pass

    def _run(self):
        import subprocess
        backoff = 5
        no_window = 0x08000000 if sys.platform.startswith("win") else 0  # CREATE_NO_WINDOW
        while not self._stop.is_set():
            if not (self.cfg.get("liaison_host") or "").strip():
                self._set("unconfigured")
                return
            self._set("connecting")
            try:
                self._proc = subprocess.Popen(
                    self._build_cmd(),
                    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                    creationflags=no_window)
            except Exception as e:
                print(f"maint tunnel spawn failed: {e}", file=sys.stderr)
                self._set("down")
                if self._stop.wait(backoff):
                    return
                backoff = min(backoff * 2, 60)
                continue
            # ssh -N 沒有明確 ready 訊號：撐過幾秒沒掛 = 視為通。
            if self._stop.wait(3):
                self._kill()
                return
            if self._proc.poll() is None:
                self._set("up")
                backoff = 5
            while self._proc.poll() is None and not self._stop.is_set():
                if self._stop.wait(2):
                    break
            if self._stop.is_set():
                self._kill()
                return
            self._set("down")          # ssh 掛了 → backoff 重連
            if self._stop.wait(backoff):
                return
            backoff = min(backoff * 2, 60)

    def start(self):
        if not self.enabled:
            self._set("disabled")
            return
        if self._thread and self._thread.is_alive():
            return
        self._stop.clear()
        self._thread = threading.Thread(target=self._run, daemon=True)
        self._thread.start()

    def stop(self):
        self._stop.set()
        self._kill()
        self._set("disabled")

    def toggle(self):
        if self.enabled and self._thread and self._thread.is_alive():
            self.enabled = False
            self.stop()
        else:
            self.enabled = True
            self.start()


# ── Liaison 控制通道 client（stage 1：登入 + 續費閘 + fail-soft 快取）──────
class LiaisonClient:
    """widget ↔ Liaison（wss://liaison.tw/agent）控制通道。

    開機連上去 → 送 login(clinic_id+secret) → 收授權結果，成功就寫本機快取。
    連不到 Liaison 時用快取 + 寬限期判定狀態——**絕不硬擋臨床使用**，只回報給 UI。
    （license_required 之後才會啟用軟性限制；stage 1 純顯示。）

    狀態 callback：on_status(status:str, info:dict)。
    """
    def __init__(self, cfg: dict, on_status=None):
        self.cfg = cfg or {}
        self.on_status = on_status or (lambda s, info: None)
        # url：.ini 覆寫優先，否則用寫死常數（全診所統一）
        self.url = (self.cfg.get("liaison_ws_url") or "").strip() or LIAISON_URL
        self.clinic_id = (self.cfg.get("clinic_id") or "").strip()
        self.secret = self.cfg.get("clinic_secret") or ""     # 過渡相容（新機不用）
        self.token = load_device_token()                      # 裝置綁定 token（DPAPI）
        self.oppa_fp = ""                                     # OPPA box 指紋（widget 取後設）
        self.on_ota = None                                    # widget-relay OTA：收 bundle 回呼
        self.oppa_content_sha = ""                            # OPPA 版本 content_sha
        self.oppa_version = ""                                # OPPA 版本號（監控用）
        self.oppa_idle = True                                 # OPPA 距前次看診 ≥30min？非 idle 不 ota_check
        self.edge_ai_entitled = False                         # 地端 AI 付費權益（login/cache 帶來）
        self._oppa_online = None                              # OPPA box 連線狀態（監控用）
        self.on_ssh_relay = None                              # 維修隧道走 8766：送 OPPA 回呼
        self.on_ota_recheck = None                            # Liaison push 出新版 → 觸發重查回呼
        self.on_widget_update = None                          # widget 自我更新：收新檔回呼
        self.version = self.cfg.get("version", "v14")
        try:
            self.grace_days = int(self.cfg.get("license_grace_days", 14))
        except Exception:
            self.grace_days = 14
        # 維修串流（stage 2）：widget 只准 pipe 到這個寫死的目的地 = 白名單
        # 維修隧道目標 OPPA：.ini 明寫 oppa_ssh_host 則固定用它（白名單）；否則跟著
        # widget WS 實際連到的 OPPA IP 走（set_oppa_ip）→ 不管 OPPA 在 .98/.99 都對。
        self._oppa_host_explicit = bool((self.cfg.get("oppa_ssh_host") or "").strip())
        self.oppa_host = (self.cfg.get("oppa_ssh_host") or "192.168.99.99").strip()
        try:
            self.oppa_port = int(self.cfg.get("oppa_ssh_port", 22))
        except Exception:
            self.oppa_port = 22
        self.maint_stream_enabled = str(
            self.cfg.get("maint_stream_enabled", "1")).strip().lower() \
            in ("1", "true", "yes", "on")
        self.status = "disabled"
        self.info = {}
        self._ws = None
        self._stop = threading.Event()
        self._thread = None
        self._online = False
        self._send_lock = threading.Lock()   # 多執行緒(心跳/維修reader)寫同一 ws
        self._streams: dict = {}              # stream_id -> {"sock","stop"}
        self._kms_result = None               # 落地加密匯出：kms_unwrap 回應暫存
        self._kms_event = threading.Event()
        self._kms_lock = threading.Lock()     # 序列化匯出解密（避免雙擊串結果）

    # ── 快取 ──
    def _load_cache(self):
        try:
            with open(LICENSE_CACHE_PATH, encoding="utf-8") as f:
                return json.load(f)
        except Exception:
            return None

    def _save_cache(self, info):
        try:
            data = {"clinic_id": self.clinic_id, "cached_at": time.time(), **info}
            with open(LICENSE_CACHE_PATH, "w", encoding="utf-8") as f:
                json.dump(data, f)
        except Exception as e:
            print(f"license cache write failed: {e}", file=sys.stderr)

    def _set(self, status, info=None):
        if LICENSE_OPEN:
            # 授權預設全開：非綠燈狀態一律視為已授權、清掉 grace 提醒、開通地端 AI 權益。
            if status not in ("licensed", "offline_ok"):
                status = "licensed"
            info = dict(info if info is not None else self.info)
            info["licensed"] = True
            info["nag"] = None
            info.setdefault("status", "active")
            self.edge_ai_entitled = True
        self.status = status
        if info is not None:
            self.info = info
        try:
            self.on_status(status, self.info)
        except Exception:
            pass

    # ── 離線判定（fail-soft）──
    def _eval_offline(self):
        c = self._load_cache()
        if not c or c.get("clinic_id") != self.clinic_id:
            self._set("unknown", {})
            return
        self.edge_ai_entitled = bool(c.get("edge_ai", False))   # 離線也記得權益
        grace = int(c.get("grace_days", self.grace_days) or self.grace_days)
        in_valid = True
        vu = c.get("valid_until")
        if vu:
            try:
                in_valid = datetime.now().date() <= \
                    datetime.strptime(vu, "%Y-%m-%d").date()
            except Exception:
                in_valid = True
        if in_valid:
            self._set("offline_ok", c)
        elif time.time() <= float(c.get("cached_at", 0)) + grace * 86400:
            self._set("grace", c)
        else:
            self._set("stale", c)

    def set_fp(self, fp: str):
        """widget 從 OPPA box 取得指紋後設進來（登入/啟用要帶）。"""
        self.oppa_fp = (fp or "").strip()

    def set_oppa_ip(self, ip: str):
        """widget WS 連上 OPPA 後同步維修隧道目標 IP（除非 .ini 已固定 oppa_ssh_host）。"""
        if ip and not self._oppa_host_explicit:
            self.oppa_host = ip.strip()

    def set_oppa_version(self, content_sha: str, on_ota, version: str = "", idle: bool = True,
                         hostname: str = "", cpu: str = "", mac: str = "", integ: str = ""):
        """widget 拿到 OPPA 版本後存起來。已連線且 OPPA 閒置(≥30min 沒看診)才 ota_check；
        非閒置就跳過(看診中零影響)，等下次定時重查/push 時 OPPA 閒了再走。
        hostname/cpu/mac＝實體機識別（回報給 Liaison）；**mac＝內建網卡，唯一、重裝不變、最穩**。
        integ＝box 關鍵檔完整性指紋（防破解，中繼給 Liaison 比對）。"""
        self.oppa_content_sha = content_sha or ""
        self.oppa_version = version or ""
        self.oppa_hostname = hostname or getattr(self, "oppa_hostname", "")
        self.oppa_cpu = cpu or getattr(self, "oppa_cpu", "")
        self.oppa_mac = mac or getattr(self, "oppa_mac", "")
        self.oppa_integ = integ or getattr(self, "oppa_integ", "")
        self.on_ota = on_ota
        self.oppa_idle = bool(idle)
        if self._online:
            self.report_oppa_state(self._oppa_online if self._oppa_online is not None else True)
        if self._online and self.oppa_idle:
            self.request_ota(self.oppa_content_sha)

    def request_ota(self, oppa_content_sha: str):
        """問 Liaison 有無更新（有則回 ota_bundle）；順帶報 OPPA 版本+實體機 hostname 供監控。"""
        self._send({"type": "ota_check", "oppa_content_sha": oppa_content_sha or "",
                    "oppa_version": self.oppa_version,
                    "oppa_hostname": getattr(self, "oppa_hostname", ""),
                    "oppa_cpu": getattr(self, "oppa_cpu", ""),
                    "oppa_mac": getattr(self, "oppa_mac", ""),
                    "integ": getattr(self, "oppa_integ", "")})   # 防破解：中繼 box 完整性指紋

    def set_feed_state(self, feed_applied: str, on_feed, idle: bool = True):
        """text-feed OTA：拿到 OPPA 的 feed 進度後，閒置才問 Liaison 缺哪幾天（回 feed_available
        → widget 逐日 feed_pull → on_feed 中繼給 box）。非閒置跳過，下次再補。"""
        self.on_feed = on_feed
        self.feed_applied = feed_applied or ""
        if self._online and idle:
            self._send({"type": "feed_check", "feed_applied": feed_applied or "",
                        "feed_have": getattr(self, "feed_have", [])})

    def force_feed_check(self):
        """醫師主動開快報 → 立刻問 Liaison 缺哪幾天（繞過 idle 閘，因為是明確要看最新）。
        帶 feed_have（box 已有的 digest 日期清單）→ Liaison 回真差集，能回補整個歷史 archive；
        沒帶則用 feed_applied 向前追。沒連 Liaison 就略過（顯示現有）。"""
        if self._online:
            self._send({"type": "feed_check",
                        "feed_applied": getattr(self, "feed_applied", "") or "",
                        "feed_have": getattr(self, "feed_have", [])})

    def report_oppa_state(self, oppa_online: bool):
        """回報 OPPA box 連線狀態 + 實體機 hostname 給 Liaison（監控用，認得出是哪台，
        不管 clinic_id）。已連 Liaison 才送；存起來供登入後補送。"""
        self._oppa_online = oppa_online
        self._send({"type": "clinic_state", "oppa_online": oppa_online,
                    "oppa_hostname": getattr(self, "oppa_hostname", ""),
                    "oppa_cpu": getattr(self, "oppa_cpu", ""),
                    "oppa_mac": getattr(self, "oppa_mac", "")})

    def report_ota(self, result: dict):
        """把 OPPA 套用結果回報 Liaison（離線診所更新的觀測）。"""
        self._send({"type": "ota_result_report",
                    "ok": result.get("ok"), "applied": result.get("applied"),
                    "version": result.get("version"), "reason": result.get("reason")})

    def activate_with_code(self, code: str) -> tuple[bool, str]:
        """一次性啟用碼 + OPPA 指紋 → 換 token（DPAPI 存）。回 (ok, reason/'')。
        同步阻塞（給 UI 按鈕呼叫）；獨立短連線，不干擾背景 _run。"""
        import websocket as _wsmod
        if not self.oppa_fp:
            return False, "no_oppa_fp"
        try:
            ws = _wsmod.create_connection(self.url, timeout=15)
            ws.send(json.dumps({"type": "activate", "code": (code or "").strip().upper(),
                                "fingerprint": self.oppa_fp}))
            m = json.loads(ws.recv()); ws.close()
        except Exception as e:
            return False, f"net:{e}"
        if not m.get("ok"):
            return False, m.get("reason", "denied")
        self.token = m["token"]
        self.clinic_id = m.get("clinic_id", self.clinic_id)
        save_device_token(self.token)
        return True, ""

    # ── WS callbacks ──
    def _on_open(self, ws):
        self._online = True
        # 新機：token + OPPA 指紋（綁裝置）；過渡：clinic_id + secret。
        if self.token:
            self._send({"type": "login", "token": self.token,
                        "fingerprint": self.oppa_fp, "version": self.version,
                        "app": "widget", "ts": time.time()})
        else:
            self._send({"type": "login", "clinic_id": self.clinic_id,
                        "secret": self.secret, "version": self.version,
                        "app": "widget", "ts": time.time()})

    def _send(self, obj):
        """執行緒安全送出（心跳 + 維修 reader 可能並發寫同一 ws）。"""
        try:
            with self._send_lock:
                if self._ws:
                    self._ws.send(json.dumps(obj))
        except Exception:
            pass

    def _on_message(self, ws, raw):
        try:
            m = json.loads(raw)
        except Exception:
            return
        t = m.get("type")
        if t in ("login_result", "heartbeat_ack"):
            if not m.get("ok", False):
                self._set("denied", {"reason": m.get("reason"),
                                     "message": m.get("message")})
                return
            info = {k: m.get(k) for k in ("status", "licensed", "valid_until",
                                          "grace_days", "plan", "name",
                                          "days_past_expiry", "nag", "edge_ai")}
            self.edge_ai_entitled = bool(m.get("edge_ai", False))   # 地端 AI 付費權益
            self._save_cache(info)
            self._set("licensed" if m.get("licensed", True) else "expired", info)
            # 登入成功 → 若已知 OPPA 版本且 OPPA 閒置(≥30min)，問 widget-relay OTA
            # （此時 ws 確定連好）。非閒置就跳過，等定時重查/push。
            if t == "login_result" and self.oppa_content_sha and self.on_ota \
                    and getattr(self, "oppa_idle", True):
                self.request_ota(self.oppa_content_sha)
            # 登入成功 → 補報目前 OPPA 連線狀態（監控）
            if t == "login_result" and self._oppa_online is not None:
                self._send({"type": "clinic_state", "oppa_online": self._oppa_online})
            # 登入成功 → widget 自我更新檢查（僅原始碼模式；.exe 不支援）
            if t == "login_result" and WIDGET_FILE:
                self._send({"type": "widget_check", "sha": _widget_self_sha()})

        if t == "widget_update" and m.get("ok") and self.on_widget_update:
            # Liaison 推來新 widget.py（有更新）→ 交 Widget 主緒處理（驗+換+重啟）
            self.on_widget_update(m.get("b64", ""), m.get("sha", ""), m.get("sig", ""))
            return
        # ── stage 2：維修串流（widget 只 pipe 到寫死的 OPPA:22）──
        elif t == "ota_bundle":
            # widget-relay OTA：Liaison 推來簽章 bundle → 交給 widget 轉給 OPPA box
            if m.get("update") and self.on_ota:
                try:
                    self.on_ota(m.get("manifest") or {}, m.get("bundle_b64") or "")
                except Exception as e:
                    print(f"on_ota failed: {e}", file=sys.stderr)
        elif t == "ota_manifest":
            pass   # update:False（已最新）或錯誤 → 不動
        elif t == "feed_available":
            # text-feed OTA：Liaison 回「缺哪幾天」→ widget 逐日拉 pack（回 feed_pack）
            if m.get("ok"):
                for d in (m.get("dates") or []):
                    self._send({"type": "feed_pull", "date": d.get("date")})
        elif t == "feed_pack":
            # text-feed OTA：Liaison 回某日簽章 pack → 交給 widget 中繼給 OPPA box
            if m.get("ok") and getattr(self, "on_feed", None):
                try:
                    self.on_feed(m.get("date"), m.get("manifest") or {}, m.get("pack_b64") or "")
                except Exception as e:
                    print(f"on_feed failed: {e}", file=sys.stderr)
        elif t == "ota_recheck":
            # Liaison 出新版主動通知 → 重新問 OPPA 版本(get_version)→ 若 OPPA 閒置(≥30min)
            # 才走 ota_check。看診中(非 idle)會被 get_version 的 idle 旗標擋下，不影響看診。
            if self.on_ota_recheck:
                try:
                    self.on_ota_recheck()
                except Exception as e:
                    print(f"on_ota_recheck failed: {e}", file=sys.stderr)
        elif t == "maint_open":
            self._maint_open(m.get("stream_id"))
        elif t == "maint_data":
            self._maint_data(m.get("stream_id"), m.get("data", ""))
        elif t == "maint_close":
            self._maint_close(m.get("stream_id"), notify=False)
        elif t == "kms_unwrap_result":
            self._kms_result = m
            self._kms_event.set()

    # ── 落地加密匯出：請 Liaison KMS 授權解開 DEK（不續約 → 被拒）──
    def kms_unwrap(self, items, timeout=45):
        """送 wrapped_dek 清單給 Liaison /agent；回 {"ok":bool,"deks":[b64...],...}。
        未連線 → offline；逾時 → timeout。授權強制在 Liaison 端。"""
        if not self._online or not self._ws:
            return {"ok": False, "reason": "offline"}
        with self._kms_lock:   # 序列化：避免並發匯出共用 event/result 串結果
            self._kms_result = None
            self._kms_event.clear()
            self._send({"type": "kms_unwrap", "items": items})
            if not self._kms_event.wait(timeout):
                return {"ok": False, "reason": "timeout"}
            return self._kms_result or {"ok": False, "reason": "no_result"}

    # ── 維修串流 handlers ──
    # 維修隧道走 8766（SSH-over-ws relay）：不開 local socket（Windows 防火牆只放行 8766），
    # 改經 widget 主 ws 把 SSH 位元組轉給 OPPA → OPPA 接本機 sshd。on_ssh_relay 由 Widget 設。
    def _maint_open(self, sid):
        if sid is None:
            return
        if not self.maint_stream_enabled or not self.on_ssh_relay:
            self._send({"type": "maint_opened", "stream_id": sid,
                        "ok": False, "reason": "disabled"})
            return
        self._streams[sid] = {"open": True}
        self.on_ssh_relay("open", sid, None)   # Widget → OPPA ssh_relay_open
        # maint_opened ok 等 OPPA 回 ssh_relay_open_ok（feed_ssh_open_ok）才送

    def _maint_data(self, sid, b64):
        if sid in self._streams:
            self.on_ssh_relay("data", sid, b64)   # admin SSH bytes → OPPA

    def _maint_close(self, sid, notify=False):
        if self._streams.pop(sid, None) is None:
            return
        try:
            self.on_ssh_relay("close", sid, None)
        except Exception:
            pass
        if notify:
            self._send({"type": "maint_close", "stream_id": sid})

    # ── OPPA 經主 ws 回來的 ssh_relay 事件（Widget 收到後轉這裡）→ 中繼回 Liaison ──
    def feed_ssh_open_ok(self, sid):
        if sid in self._streams:
            self._send({"type": "maint_opened", "stream_id": sid, "ok": True})

    def feed_ssh_data(self, sid, b64):
        if sid in self._streams:
            self._send({"type": "maint_data", "stream_id": sid, "data": b64})

    def feed_ssh_close(self, sid):
        if self._streams.pop(sid, None) is not None:
            self._send({"type": "maint_close", "stream_id": sid})

    def _close_all_streams(self):
        for sid in list(self._streams):
            self._maint_close(sid, notify=False)

    def _on_close(self, ws, *a):
        self._online = False
        self._close_all_streams()      # 連線斷 → 收掉所有維修串流
        if not self._stop.is_set():
            self._eval_offline()

    def _on_error(self, ws, e):
        print(f"liaison WS error: {e}", file=sys.stderr)

    def _heartbeat_loop(self, ws):
        # 每 30 分鐘心跳 → 拿最新授權（續費生效 / 即時撤銷）
        while not self._stop.is_set() and self._online:
            if self._stop.wait(1800):
                return
            self._send({"type": "heartbeat",
                        "clinic_id": self.clinic_id, "ts": time.time()})

    def _run(self):
        while not self._stop.is_set():
            self._set("checking")
            try:
                self._ws = websocket.WebSocketApp(
                    self.url,
                    on_open=self._on_open,
                    on_message=self._on_message,
                    on_close=self._on_close,
                    on_error=self._on_error)
                threading.Thread(
                    target=lambda w=self._ws: self._heartbeat_loop(w),
                    daemon=True).start()
                self._ws.run_forever(ping_interval=60, ping_timeout=20)
            except Exception as e:
                print(f"liaison WS exception: {e}", file=sys.stderr)
            if self._stop.is_set():
                return
            self._eval_offline()          # 斷線 → 用快取判定狀態
            if self._stop.wait(30):       # 30s 後重連
                return

    def start(self):
        # 有 token（新機）或 clinic_id+secret（過渡）才連背景登入/心跳；
        # 都沒有 → 等啟用（needs_activation），由 widget 提示輸入啟用碼。
        if not self.url:
            self._set("disabled"); return
        if not self.token and not (self.clinic_id and self.secret):
            self._set("needs_activation"); return
        if self._thread and self._thread.is_alive():
            return
        self._stop.clear()
        self._thread = threading.Thread(target=self._run, daemon=True)
        self._thread.start()

    def stop(self):
        self._stop.set()
        self._close_all_streams()
        try:
            if self._ws:
                self._ws.close()
        except Exception:
            pass


# ── Widget ──────────────────────────────────────────────────────────────
class Widget:
    def __init__(self, server_ip, port, hotkey, subnet, scan_timeout,
                 maint_cfg=None, liaison_cfg=None):
        self.server_ip = server_ip
        self.port = port
        self.hotkey = hotkey
        self.subnet = subnet
        self.scan_timeout = scan_timeout
        # 維修隧道（widget 當只開一條縫的閘口）— 狀態回 UI 走 root.after
        self.maint = MaintTunnel(maint_cfg, on_status=self._on_maint_status)
        # Liaison 控制通道（登入 + 續費閘 + fail-soft 快取）
        self.liaison = LiaisonClient(liaison_cfg, on_status=self._on_license_status)
        self.liaison.on_ssh_relay = self._send_ssh_relay   # 維修隧道走 8766：經主 ws 送 OPPA
        self.liaison.on_widget_update = self._apply_widget_update  # widget 自我更新
        self.liaison.on_ota_recheck = self._send_get_version       # Liaison push 出新版→重查
        self._pending_widget_update = None                         # 看診中延後的更新
        self.current_ip = None
        self.ws = None
        self.state = "offline"
        self.last_soap_text = ""
        self.last_sections = {"S": "", "O": "", "A": "", "P": ""}
        self.last_dx = ""
        self._soap_event = threading.Event()
        self._soap_pending = ""
        # V13 widget 最近 N 筆下拉：cached + selection state
        # _recent_soaps：本日 sessions list；_selected_wav：當前顯示哪筆
        # （None 表示「跟著 latest」，selected 表示用戶手動選了某筆）
        # _version：當前顯示版本 "v13"（fast ASR + draft LLM）或 "v12"
        # （slow ASR + full LLM，audit.json 內）
        self._recent_soaps: list[dict] = []
        self._selected_wav: str | None = None
        self._version: str = "v13"
        # 問答視窗（獨立 Toplevel）：句柄 + 多輪 history（widget 端維護，
        # 隨每次 qa_ask 帶給 server → server 無狀態）+ 是否正在等回答
        self._qa_win: tk.Toplevel | None = None
        self._homecare_win: tk.Toplevel | None = None   # 長照分頁（單例，__init__ 先初始化）
        self._last_soap_for_his = None                   # HIS 整合：最近 SOAP（拉/推用）
        self._edge_ai_token = ""                          # 地端 AI 模式 token（OPPA 廣播來）
        self._edge_pending = {}                           # 地端 AI relay：id → {event,status,body}
        self._edge_srv = None                             # 地端 AI HTTP server（widget-bridge）
        self._edge_seq = 0
        self._ws_send_lock = threading.Lock()             # 多請求緒同時送 OPPA ws → 防撞幀
        self._local_llm_srv = None                        # 第一方內部本地 LLM 端點（always-on）
        self._local_llm_token = ""
        self._qa_log: tk.Text | None = None
        self._qa_input: tk.Text | None = None
        self._qa_history: list[dict] = []
        self._qa_waiting = False

        # 繪圖視窗（地端 AI 模式才可用）：句柄 + 目前圖（PhotoImage ref 防 GC + 原始 PNG bytes 供下載）
        self._edge_ai_on = False                          # kiosk 是否在地端 AI 模式（廣播來）
        self._draw_win: tk.Toplevel | None = None
        self._draw_btn: tk.Button | None = None           # 標題列「繪圖」鈕（非地端模式 disabled）
        self._draw_img = None                             # tk.PhotoImage（keep ref）
        self._draw_png: bytes | None = None               # 原始 PNG bytes（下載用）
        self._draw_waiting = False
        self._draw_pbar = None                            # 生圖進度條（不確定型跑馬燈）
        self._draw_tick_job = None                        # 經過秒數更新的 after job
        self._draw_t0 = 0.0
        # 文獻庫管理視窗（醫師上傳 PDF → doctor_docs RAG）
        self._docs_win: tk.Toplevel | None = None
        self._digest_win: tk.Toplevel | None = None    # 📰 今日文獻快報視窗（單例）
        self._digest_dates = []
        self._docs_listframe = None
        self._docs_status = None
        # 衛教草稿視窗（grounded 在衛教單 RAG）
        self._edu_win: tk.Toplevel | None = None
        self._edu_topic = None
        self._edu_plan = None
        self._edu_text = None
        self._edu_status = None
        self._edu_btn2 = None      # 生成鈕（視窗內）
        self._edu_waiting = False
        self._edu_content = ""

        # Tk
        self.root = tk.Tk()
        self.root.title("OPPA")
        self.root.overrideredirect(True)
        self.root.attributes("-topmost", True)
        # 收合浮空圖示透明去黑框：把畫布底色設成色鍵 → Windows 以 -transparentcolor 穿透，
        # 圓角外露桌面、無深色框。非 Windows（Mac/Linux 不支援）退回深底。搭配硬邊 icon(不留色鍵 fringe)。
        self._chroma = None
        if sys.platform.startswith("win"):
            try:
                self.root.wm_attributes("-transparentcolor", "#ff00ff")
                self._chroma = "#ff00ff"
            except Exception:
                self._chroma = None
        self._collapsed_bg = self._chroma or "#1d2228"
        self.collapsed = True
        self._drag_data = {"x": 0, "y": 0}
        self._build_ui()
        # 預設位置：左上角
        self.root.update_idletasks()
        self.root.geometry("+0+0")

    # ── UI construction ─────────────────────────────────────────────
    def _build_ui(self):
        # === Collapsed view: 圓圈 ===
        self.collapsed_frame = tk.Frame(self.root, bg=self._collapsed_bg, padx=0, pady=0)
        self.canvas = tk.Canvas(self.collapsed_frame, width=60, height=60,
                                 bg=self._collapsed_bg, highlightthickness=0)
        self.canvas.pack()
        # 整個方形 = OPPA favicon。載入失敗 fallback 回文字。
        # tk.PhotoImage 載 PNG（Tk 8.6+），keep ref 防 GC。
        self._icon_img = None
        try:
            # PyInstaller onefile 解到 sys._MEIPASS；純 script 則用 __file__ 旁邊。
            _base = getattr(sys, "_MEIPASS", None)
            _icon = (Path(_base) if _base else Path(__file__).resolve().parent) / "oppa_icon.png"
            self._icon_img = tk.PhotoImage(file=str(_icon))
            self._label = self.canvas.create_image(30, 30, image=self._icon_img)
        except Exception:
            self._label = self.canvas.create_text(30, 30, text="OPPA",
                                                   fill="#ffffff",
                                                   font=("Segoe UI", 13, "bold"))
        # 圖示最下方左右並列兩條短線（覆蓋在 favicon 底部，取代原本角落圓點）：
        #   左線 = 連線狀態（灰=離線 / 綠=待機 / 琥珀=錄音…）
        #   右線 = 授權狀態（綠=授權 / 琥珀=寬限到期 / 紅=被拒 / 暗=未設定）
        self._dot = self.canvas.create_rectangle(7, 52, 28, 56,
                                            fill=STATE_COLOR["offline"],
                                            outline=self._collapsed_bg, width=1)
        self._license_dot = self.canvas.create_rectangle(32, 52, 53, 56,
                                            fill=LICENSE_COLOR["disabled"],
                                            outline=self._collapsed_bg, width=1)
        # Left-click 區分 click vs drag (motion < 4px = click → expand)
        self._press_xy = (0, 0)
        self._dragged = False
        for w in (self.canvas, self.collapsed_frame):
            w.bind("<ButtonPress-1>", self._press_start)
            w.bind("<B1-Motion>", self._press_move)
            w.bind("<ButtonRelease-1>", self._press_release)
            w.bind("<Button-3>", self._show_collapsed_menu)
        # 右鍵 context menu (collapsed view)
        self.collapsed_menu = tk.Menu(self.root, tearoff=0,
                                       bg="#21262d", fg="#c9d1d9",
                                       activebackground="#30363d")
        # 授權(續費)狀態列（不可點，由 _update_license_ui 更新文字）
        self.collapsed_menu.add_command(label="授權：檢查中…", state="disabled")
        self._license_menu_idx = self.collapsed_menu.index("end")
        self.collapsed_menu.add_separator()
        self.collapsed_menu.add_command(label="展開", command=self.expand)
        self.collapsed_menu.add_command(label="重新掃描網路",
                                         command=self.rescan_now)
        self.collapsed_menu.add_separator()
        # 維修通道（SSH-over-8766）永遠開啟、不提供 UI 開關——支援用通道不讓診所端誤關，
        # 也不在選單揭露其存在。狀態由 Liaison 端監看，不在 widget 顯示。
        self.collapsed_menu.add_command(label="換 token（重新啟用）",
                                         command=self._reactivate)
        self.collapsed_menu.add_command(label="編輯設定 (.ini)",
                                         command=self.open_settings)
        self.collapsed_menu.add_separator()
        self.collapsed_menu.add_command(label="退出 OPPA Widget",
                                         command=self.quit_app)

        # === Expanded view: 2x2 grid + status bar + 整段 paste ===
        self.expanded_frame = tk.Frame(self.root, bg="#0d1117", padx=8, pady=6)
        # title bar
        title_bar = tk.Frame(self.expanded_frame, bg="#0d1117")
        title_bar.pack(fill="x", pady=(0, 4))
        self.title_state = tk.Label(title_bar, text="OPPA — 離線",
                                     bg="#0d1117", fg="#c9d1d9",
                                     font=("Segoe UI", 10, "bold"))
        self.title_state.pack(side="left")
        tk.Button(title_bar, text="💬 問答", command=self.open_qa_window,
                  bg="#21262d", fg="#a5d6ff", borderwidth=0,
                  cursor="hand2", padx=8, pady=0, font=("Segoe UI", 9, "bold"),
                  activebackground="#30363d"
                  ).pack(side="left", padx=(10, 0))
        tk.Button(title_bar, text="🏠 長照", command=self.open_homecare_window,
                  bg="#21262d", fg="#ffd6a5", borderwidth=0,
                  cursor="hand2", padx=8, pady=0, font=("Segoe UI", 9, "bold"),
                  activebackground="#30363d"
                  ).pack(side="left", padx=(6, 0))
        # 繪圖已移到「研究 widget」→ 看診 widget 不再放繪圖鈕（_draw_btn 維持 None，
        # _update_draw_btn 會 guard 略過；底層 _draw_* 方法留著但無入口，之後可整批清）。
        # 文獻庫（上傳醫師自己的 PDF → 問答時引用）。不需地端 AI 模式。
        tk.Button(title_bar, text="📚 文獻", command=self.open_docs_window,
                  bg="#21262d", fg="#7ee787", borderwidth=0,
                  cursor="hand2", padx=8, pady=0, font=("Segoe UI", 9, "bold"),
                  activebackground="#30363d"
                  ).pack(side="left", padx=(6, 0))
        # 今日文獻快報（每日頂刊新文摘要，讀 box 的 digest；不需地端 AI 模式）。
        tk.Button(title_bar, text="📰 快報", command=self.open_digest_window,
                  bg="#21262d", fg="#79c0ff", borderwidth=0,
                  cursor="hand2", padx=8, pady=0, font=("Segoe UI", 9, "bold"),
                  activebackground="#30363d"
                  ).pack(side="left", padx=(6, 0))
        # 衛教草稿（grounded 在權威衛教單，須醫師審閱）
        tk.Button(title_bar, text="✍️ 衛教", command=self.open_edu_window,
                  bg="#21262d", fg="#ffab70", borderwidth=0,
                  cursor="hand2", padx=8, pady=0, font=("Segoe UI", 9, "bold"),
                  activebackground="#30363d"
                  ).pack(side="left", padx=(6, 0))
        tk.Button(title_bar, text="✕", command=self.collapse,
                  bg="#0d1117", fg="#c9d1d9",
                  borderwidth=0, font=("Segoe UI", 11),
                  cursor="hand2", padx=4, pady=0,
                  activebackground="#21262d"
                  ).pack(side="right")
        tk.Button(title_bar, text="⏻", command=self.quit_app,
                  bg="#0d1117", fg="#d23c3c",
                  borderwidth=0, font=("Segoe UI", 11),
                  cursor="hand2", padx=4, pady=0,
                  activebackground="#21262d"
                  ).pack(side="right", padx=(0, 4))
        title_bar.bind("<ButtonPress-1>", self._drag_start)
        title_bar.bind("<B1-Motion>", self._drag_move)
        self.title_state.bind("<ButtonPress-1>", self._drag_start)
        self.title_state.bind("<B1-Motion>", self._drag_move)

        # V13：本日 SOAP 下拉（選任一筆顯示） + V13/V12 toggle
        dropdown_row = tk.Frame(self.expanded_frame, bg="#0d1117")
        dropdown_row.pack(fill="x", pady=(0, 4))
        tk.Label(dropdown_row, text="本日:", bg="#0d1117", fg="#8b949e",
                 font=("Segoe UI", 9)).pack(side="left", padx=(0, 4))
        self.dropdown_var = tk.StringVar(value="(等待 SOAP…)")
        self.dropdown = ttk.Combobox(dropdown_row, textvariable=self.dropdown_var,
                                       state="readonly", width=36,
                                       font=("Segoe UI", 9))
        self.dropdown.pack(side="left", fill="x", expand=True)
        self.dropdown.bind("<<ComboboxSelected>>", self._on_dropdown_select)
        tk.Button(dropdown_row, text="↻", command=self.refresh_recent_soaps,
                  bg="#21262d", fg="#c9d1d9", borderwidth=0,
                  cursor="hand2", padx=6,
                  activebackground="#30363d"
                  ).pack(side="right", padx=(4, 0))

        # 匯出 + 防核刪 同一列：紀錄(當日/當月,CSV) | 防核刪報告(昨日/當月,.md)
        export_row = tk.Frame(self.expanded_frame, bg="#0d1117")
        export_row.pack(fill="x", pady=(0, 4))
        tk.Label(export_row, text="匯出:", bg="#0d1117", fg="#8b949e",
                 font=("Segoe UI", 9)).pack(side="left", padx=(0, 4))
        tk.Button(export_row, text="當日",
                  command=lambda: self.export_records("today"),
                  bg="#238636", fg="white", borderwidth=0, cursor="hand2",
                  padx=8, pady=2, font=("Segoe UI", 9, "bold"),
                  activebackground="#2ea043").pack(side="left", padx=(0, 3))
        tk.Button(export_row, text="當月",
                  command=lambda: self.export_records("month"),
                  bg="#21262d", fg="#c9d1d9", borderwidth=0, cursor="hand2",
                  padx=8, pady=2, font=("Segoe UI", 9),
                  activebackground="#30363d").pack(side="left", padx=(0, 8))
        tk.Label(export_row, text="防核刪稽核:", bg="#0d1117", fg="#8b949e",
                 font=("Segoe UI", 9)).pack(side="left", padx=(0, 4))
        tk.Button(export_row, text="選擇日期",
                  command=self._pick_recon_date,
                  bg="#238636", fg="white", borderwidth=0, cursor="hand2",
                  padx=8, pady=2, font=("Segoe UI", 9, "bold"),
                  activebackground="#2ea043").pack(side="left", padx=(0, 3))
        tk.Button(export_row, text="整月",
                  command=lambda: self.export_recon("month"),
                  bg="#21262d", fg="#c9d1d9", borderwidth=0, cursor="hand2",
                  padx=8, pady=2, font=("Segoe UI", 9),
                  activebackground="#30363d").pack(side="left")
        tk.Button(export_row, text="🛡️今日核刪檢查",
                  command=self.request_nhi_audit,
                  bg="#9e6a03", fg="white", borderwidth=0, cursor="hand2",
                  padx=8, pady=2, font=("Segoe UI", 9, "bold"),
                  activebackground="#bb8009").pack(side="right")

        # 版本 toggle 已隱藏（現在統一 V14）— 仍建立 widget 物件避免其他程式
        # (_update_version_buttons / _on_version_toggle) AttributeError,但整列不 pack=不顯示
        toggle_row = tk.Frame(self.expanded_frame, bg="#0d1117")
        tk.Label(toggle_row, text="版本:", bg="#0d1117", fg="#8b949e",
                 font=("Segoe UI", 9)).pack(side="left", padx=(0, 4))
        self.v13_btn = tk.Button(toggle_row, text="V13 (本)",
                                  command=lambda: self._on_version_toggle("v13"),
                                  bg="#1f6feb", fg="white", borderwidth=0,
                                  cursor="hand2", padx=10, pady=2,
                                  font=("Segoe UI", 9, "bold"),
                                  activebackground="#388bfd")
        self.v13_btn.pack(side="left", padx=(0, 4))
        self.v12_btn = tk.Button(toggle_row, text="V12 audit (對照)",
                                  command=lambda: self._on_version_toggle("v12"),
                                  bg="#21262d", fg="#8b949e", borderwidth=0,
                                  cursor="hand2", padx=10, pady=2,
                                  font=("Segoe UI", 9),
                                  activebackground="#30363d")
        self.v12_btn.pack(side="left")
        self.dx_match_label = tk.Label(toggle_row, text="", bg="#0d1117",
                                        fg="#8b949e",
                                        font=("Segoe UI", 8, "italic"))
        self.dx_match_label.pack(side="left", padx=(8, 0))

        # 2x2 grid
        grid = tk.Frame(self.expanded_frame, bg="#0d1117")
        grid.pack(fill="both", expand=True)
        self.section_widgets = {}
        positions = [("S", 0, 0), ("O", 0, 1), ("A", 1, 0), ("P", 1, 1)]
        for sec, r, c in positions:
            cell = tk.Frame(grid, bg="#161b22",
                            highlightbackground="#30363d",
                            highlightthickness=1, padx=4, pady=4)
            cell.grid(row=r, column=c, padx=3, pady=3, sticky="nsew")
            # Header (sec letter + 提示「點擊複製」)
            hdr = tk.Frame(cell, bg="#161b22")
            hdr.pack(fill="x")
            tk.Label(hdr, text=sec, bg="#161b22", fg="#79c0ff",
                     font=("Segoe UI", 11, "bold")).pack(side="left")
            copy_btn = tk.Label(hdr, text="📋 點此複製",
                                bg="#161b22", fg="#6e7681",
                                cursor="hand2",
                                font=("Segoe UI", 8))
            copy_btn.pack(side="right")
            txt = tk.Text(cell, height=6, width=34, wrap="word",
                          bg="#0d1117", fg="#c9d1d9",
                          relief="flat", padx=4, pady=2,
                          font=("Consolas", 9),
                          highlightthickness=0)
            txt.pack(fill="both", expand=True, pady=(2, 0))
            txt.config(state="disabled", cursor="hand2")
            # 整個 cell + textarea 都點得到複製
            for w in (cell, hdr, txt, copy_btn):
                w.bind("<Button-1>",
                       lambda e, s=sec: self.copy_section(s))
            self.section_widgets[sec] = txt

        for r in range(2): grid.rowconfigure(r, weight=1)
        for c in range(2): grid.columnconfigure(c, weight=1)

        # Bottom bar: dx + paste-all 按鈕
        bottom = tk.Frame(self.expanded_frame, bg="#0d1117")
        bottom.pack(fill="x", pady=(6, 0))
        self.dx_label = tk.Label(bottom, text="", bg="#0d1117",
                                  fg="#a5d6ff",
                                  font=("Segoe UI", 9, "italic"),
                                  anchor="w")
        self.dx_label.pack(side="left", fill="x", expand=True)
        tk.Button(bottom, text="↻", command=self.refresh_soap,
                  bg="#21262d", fg="#c9d1d9", borderwidth=0,
                  cursor="hand2", padx=6,
                  activebackground="#30363d"
                  ).pack(side="right", padx=(4, 0))
        tk.Button(bottom, text="📋 整段複製",
                  command=self.copy_all,
                  bg="#238636", fg="#ffffff", borderwidth=0,
                  cursor="hand2", padx=8, pady=2,
                  font=("Segoe UI", 9, "bold"),
                  activebackground="#2ea043"
                  ).pack(side="right")

        # ── 舊病歷：貼上 → 套用 → 隨「下一次」SOAP 生成當背景參考帶入，
        #    生成後 server 會 broadcast 清空（避免殘留到下個病人）。
        prior_frame = tk.Frame(self.expanded_frame, bg="#0d1117")
        prior_frame.pack(fill="x", pady=(6, 0))
        prior_hdr = tk.Frame(prior_frame, bg="#0d1117")
        prior_hdr.pack(fill="x")
        tk.Label(prior_hdr, text="舊病歷（點一下＝貼入剪貼簿並套用；隨下次生成帶入）",
                 bg="#0d1117", fg="#8b949e",
                 font=("Segoe UI", 8)).pack(side="left")
        self.prior_status = tk.Label(prior_hdr, text="", bg="#0d1117",
                                     fg="#3fb950", font=("Segoe UI", 8))
        self.prior_status.pack(side="right")
        self.prior_text = tk.Text(prior_frame, height=4, wrap="word",
                                  bg="#0d1117", fg="#c9d1d9",
                                  relief="flat", padx=4, pady=2,
                                  insertbackground="#c9d1d9",
                                  font=("Consolas", 9),
                                  highlightbackground="#30363d",
                                  highlightthickness=1)
        self.prior_text.pack(fill="x", pady=(2, 0))
        # 點一下空格子 → 自動貼入剪貼簿 + 套用（醫師從 HIS 複製舊病歷後一鍵帶入）
        self.prior_text.bind("<Button-1>", self._prior_click_paste)
        prior_btns = tk.Frame(prior_frame, bg="#0d1117")
        prior_btns.pack(fill="x", pady=(2, 0))
        tk.Button(prior_btns, text="套用舊病歷",
                  command=self.apply_prior_record,
                  bg="#1f6feb", fg="#ffffff", borderwidth=0,
                  cursor="hand2", padx=8, pady=2,
                  font=("Segoe UI", 9, "bold"),
                  activebackground="#388bfd").pack(side="left")
        tk.Button(prior_btns, text="清除",
                  command=self.clear_prior_record,
                  bg="#21262d", fg="#c9d1d9", borderwidth=0,
                  cursor="hand2", padx=8, pady=2,
                  activebackground="#30363d").pack(side="left", padx=(6, 0))

        # 預設 collapsed
        self.collapsed_frame.pack()

    # ── Drag (collapsed: 左鍵壓著拖，鬆開若無位移 = expand) ─────────
    def _press_start(self, event):
        self._press_xy = (event.x_root, event.y_root)
        self._drag_data["x"] = event.x_root - self.root.winfo_x()
        self._drag_data["y"] = event.y_root - self.root.winfo_y()
        self._dragged = False

    def _press_move(self, event):
        dx = abs(event.x_root - self._press_xy[0])
        dy = abs(event.y_root - self._press_xy[1])
        if dx > 4 or dy > 4:
            self._dragged = True
            x = event.x_root - self._drag_data["x"]
            y = event.y_root - self._drag_data["y"]
            self.root.geometry(f"+{x}+{y}")

    def _press_release(self, event):
        if not self._dragged:
            self.toggle_expand()

    def _show_collapsed_menu(self, event):
        try:
            self.collapsed_menu.tk_popup(event.x_root, event.y_root)
        finally:
            self.collapsed_menu.grab_release()

    # ── Drag (expanded: title bar) ───────────────────────────────
    def _drag_start(self, event):
        self._drag_data["x"] = event.x_root - self.root.winfo_x()
        self._drag_data["y"] = event.y_root - self.root.winfo_y()

    def _drag_move(self, event):
        x = event.x_root - self._drag_data["x"]
        y = event.y_root - self._drag_data["y"]
        self.root.geometry(f"+{x}+{y}")

    # ── Menu actions ────────────────────────────────────────────
    def rescan_now(self):
        if self.ws:
            try:
                self.ws.close()
            except Exception:
                pass

    def open_settings(self):
        import subprocess
        try:
            subprocess.Popen(["notepad", str(CONFIG_PATH)])
        except Exception as e:
            print(f"open settings failed: {e}", file=sys.stderr)

    # ── 維修隧道 UI ───────────────────────────────────────────────
    def _on_maint_status(self, status: str):
        """MaintTunnel(stage1 舊 ssh -R) 的 status callback。維修通道改走 SSH-over-8766
        且選單已移除維修 UI → 不再更新任何 widget 顯示，僅吞掉 callback。"""
        pass

    # ── 授權(續費)狀態 UI ─────────────────────────────────────────
    def _on_license_status(self, status: str, info: dict):
        """LiaisonClient 背景執行緒 callback → 丟回 Tk 主執行緒更新。"""
        try:
            self.root.after(0, lambda: self._update_license_ui(status, info))
        except Exception:
            pass

    def _liaison_start_fallback(self):
        """連不到 OPPA（拿不到指紋）時的保險：若 Liaison 還沒登入過且有憑證，仍起。
        token 登入沒指紋會被拒，但 secret 登入不需指紋 → 至少授權/維修隧道可用。"""
        if self.liaison.status in ("disabled",) and \
                (self.liaison.token or (self.liaison.clinic_id and self.liaison.secret)):
            self.liaison.start()

    def _apply_widget_update(self, b64: str, sha: str, sig: str):
        """widget 自我更新（LiaisonClient 背景緒 → 排到主緒）：驗 sha(+簽章)→ py_compile
        → 備份 → 換檔 → 自我重啟。看診中(recording)延後到閒置才換，不打斷錄音。"""
        def _go():
            if not WIDGET_FILE:
                return
            try:
                data = base64.b64decode(b64)
            except Exception:
                return
            # 1) sha256 完整性（走已認證 wss，必驗）
            if hashlib.sha256(data).hexdigest() != sha:
                print("widget update: sha 不符,放棄", file=sys.stderr); return
            # 2) ed25519 簽章（有 cryptography 才驗;沒有則靠 wss/TLS+sha）
            if sig:
                try:
                    from cryptography.hazmat.primitives.serialization import load_pem_public_key
                    load_pem_public_key(_OTA_PUB_PEM).verify(base64.b64decode(sig), bytes.fromhex(sha))
                except ImportError:
                    pass   # 無 lib → 略過簽章(已有 sha+TLS)
                except Exception:
                    print("widget update: 簽章驗證失敗,放棄", file=sys.stderr); return
            # 3) 看診中延後（避免重啟打斷錄音）
            if self.state == "recording":
                self._pending_widget_update = (b64, sha, sig)
                print("widget update: 看診中,延後到閒置再套用", file=sys.stderr); return
            # 4) py_compile 守門（語法壞不換）
            newf = WIDGET_FILE + ".new"
            try:
                Path(newf).write_bytes(data)
                import py_compile
                py_compile.compile(newf, doraise=True)
            except Exception as e:
                print(f"widget update: py_compile 失敗,放棄 {e}", file=sys.stderr)
                try: os.remove(newf)
                except Exception: pass
                return
            # 4.5) import 實跑驗證（僅 frozen + 新啟動器支援時）：py_compile 只驗語法、
            # 不驗 import。用同一凍結解譯器把 candidate 當「非 __main__ 模組」載入
            # (launcher --validate)，實跑其 top-level import → 擋掉「語法過但換上才
            # ModuleNotFoundError」的壞更新（就是 webbrowser 那次變磚的原因）。
            # 舊啟動器不設 OPPA_WIDGET_CAN_VALIDATE → 跳過（避免舊 launcher 誤啟 GUI）。
            if _IS_FROZEN and os.environ.get("OPPA_WIDGET_CAN_VALIDATE") == "1":
                try:
                    import subprocess
                    r = subprocess.run([sys.executable, "--validate", newf], timeout=30,
                                       stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
                    if r.returncode != 0:
                        print("widget update: import 驗證失敗,放棄\n"
                              + (r.stderr or b"").decode("utf-8", "replace")[:500],
                              file=sys.stderr)
                        try: os.remove(newf)
                        except Exception: pass
                        return
                except Exception as e:
                    # 驗證跑不起來（非「驗證結果為壞」）→ 不擋更新，交給啟動器 crash 回滾兜底
                    print(f"widget update: import 驗證無法執行({e}),續用 py_compile", file=sys.stderr)
            # 5) 備份 → 換檔 → 重啟
            try:
                import shutil
                shutil.copy2(WIDGET_FILE, WIDGET_FILE + ".bak")
                os.replace(newf, WIDGET_FILE)
            except Exception as e:
                print(f"widget update: 換檔失敗 {e}", file=sys.stderr); return
            print(f"widget update: 已更新到 {sha[:12]},重啟…", file=sys.stderr)
            try:
                messagebox.showinfo("OPPA", "widget 已更新,即將重啟。")
            except Exception:
                pass
            # 重啟：frozen 啟動器 → 重跑 exe(它會再載入更新後的鬆散 widget.py);
            # 純原始碼 → python widget.py。
            if _IS_FROZEN:
                os.execv(sys.executable, [sys.executable] + sys.argv[1:])
            else:
                os.execv(sys.executable, [sys.executable, WIDGET_FILE] + sys.argv[1:])
        self.root.after(0, _go)

    def _send_ssh_relay(self, action, sid, data):
        """維修隧道走 8766：LiaisonClient(背景緒)→ 經主 ws 送 ssh_relay_* 給 OPPA。"""
        def _go():
            if not self.ws or self.state == "offline":
                return
            try:
                msg = {"type": f"ssh_relay_{action}", "sid": sid}
                if action == "data":
                    msg["data"] = data
                self.ws.send(json.dumps(msg))
            except Exception:
                pass
        self.root.after(0, _go)

    def _relay_ota(self, manifest: dict, bundle_b64: str):
        """widget-relay OTA：Liaison 推來的簽章 bundle → 經主 ws 轉給 OPPA box 套用。
        （從 Liaison client 背景緒呼叫 → 用 root.after 切回主緒送 ws。）"""
        def _send():
            if not self.ws or self.state == "offline":
                return
            try:
                self.ws.send(json.dumps({"type": "ota_apply",
                                         "manifest": manifest, "bundle_b64": bundle_b64}))
                print(f"OTA: 轉發 bundle {manifest.get('version')} 給 OPPA",
                      file=sys.stderr)
            except Exception as e:
                print(f"OTA relay failed: {e}", file=sys.stderr)
        self.root.after(0, _send)

    def _relay_feed(self, date: str, manifest: dict, pack_b64: str):
        """text-feed OTA：Liaison 來的簽章 pack → 經主 ws 轉給 OPPA box 套用（背景 re-embed）。"""
        def _send():
            if not self.ws or self.state == "offline":
                return
            try:
                self.ws.send(json.dumps({"type": "feed_apply", "date": date,
                                         "manifest": manifest, "pack_b64": pack_b64}))
                print(f"feed: 轉發 pack {date} 給 OPPA", file=sys.stderr)
            except Exception as e:
                print(f"feed relay failed: {e}", file=sys.stderr)
        self.root.after(0, _send)

    def _reactivate(self):
        """換 token：清掉目前裝置 token → 用新啟用碼重新綁定（token 失效/換機/重裝用）。"""
        if not messagebox.askyesno(
                "換 token（重新啟用）",
                "將清除這台目前的授權綁定,改用新的啟用碼重新綁定 OPPA。\n"
                "(需要管理者提供新啟用碼)\n\n繼續?"):
            return
        try:
            TOKEN_PATH.unlink()
        except Exception:
            pass
        self.liaison.token = ""
        self.root.after(150, self._prompt_activation)

    def _prompt_activation(self):
        """待啟用：跳視窗請輸入一次性啟用碼 → activate（換裝置 token）。"""
        code = simpledialog.askstring(
            "OPPA 啟用",
            "首次使用，請輸入 OPPA 提供的啟用碼（例 ABCD-1234）：",
            parent=self.root)
        if not code:
            return
        ok, reason = self.liaison.activate_with_code(code)
        if ok:
            messagebox.showinfo("OPPA 啟用", "啟用成功！")
            self.liaison.start()          # 用新 token 連線登入
        else:
            msg = {"bad_code": "啟用碼錯誤", "code_used": "此啟用碼已使用過",
                   "code_expired": "啟用碼已過期", "no_oppa_fp": "尚未連上 OPPA 主機",
                   }.get(reason, f"啟用失敗：{reason}")
            if messagebox.askretrycancel("OPPA 啟用", msg + "\n\n重新輸入？"):
                self.root.after(100, self._prompt_activation)

    def _update_license_ui(self, status: str, info: dict):
        # 地端 AI / 本地 AI 平台 = 訂閱核心價值(非加購)→ 訂閱有效就能用,斷訂閱才失去。
        # entitled = 訂閱在效期/寬限內(非 denied/stale/unknown)。回報給 OPPA 當入場閘。
        try:
            if self.ws and self.state != "offline":
                active = status not in ("denied", "stale", "unknown")
                lapsed = status in ("denied", "stale")   # 只有確認斷訂閱才送關閉訊號
                self.ws.send(json.dumps({"type": "edge_ai_entitlement",
                                         "entitled": active, "lapsed": lapsed}))
        except Exception:
            pass
        try:
            self.canvas.itemconfig(self._license_dot,
                                   fill=LICENSE_COLOR.get(status, "#787878"))
        except Exception:
            pass
        lab = LICENSE_LABEL.get(status, status)
        vu = (info or {}).get("valid_until")
        if vu and status in ("licensed", "offline_ok", "grace", "expired"):
            lab = f"{lab}（至 {vu}）"
        try:
            self.collapsed_menu.entryconfig(self._license_menu_idx,
                                            label=f"授權：{lab}")
        except Exception:
            pass
        # 轉發 grace 漸進提醒給 OPPA server → server 推 kiosk（開機通知 / 診號前提醒）。
        nag = (info or {}).get("nag")
        if nag and self.ws and self.state != "offline":
            try:
                self.ws.send(json.dumps({"type": "license_nag", "nag": nag,
                                         "valid_until": vu}))
            except Exception:
                pass

    def quit_app(self):
        try:
            self.maint.stop()
        except Exception:
            pass
        try:
            self.liaison.stop()
        except Exception:
            pass
        try:
            if self.ws:
                self.ws.close()
        except Exception:
            pass
        try:
            self.root.destroy()
        except Exception:
            pass
        sys.exit(0)

    # ── Expand / Collapse ──────────────────────────────────────────
    def toggle_expand(self):
        if self.collapsed:
            self.expand()
        else:
            self.collapse()

    def expand(self):
        if not self.collapsed:
            return
        self.collapsed_frame.pack_forget()
        self.expanded_frame.pack()
        self.collapsed = False
        self.refresh_soap()
        self.refresh_recent_soaps()
        # 點 SOAP 視窗以外 → 收回圖示：搶焦點，焦點離開整個 app 時收合
        self.root.update_idletasks()
        self.root.focus_force()
        self.root.bind("<FocusOut>", self._on_focus_out)

    def collapse(self):
        if self.collapsed:
            return
        self.root.unbind("<FocusOut>")
        self.expanded_frame.pack_forget()
        self.collapsed_frame.pack()
        self.collapsed = True

    def _on_focus_out(self, event=None):
        # 延遲檢查讓焦點切換 settle（點到視窗內子元件不該收合）
        self.root.after(1, self._check_focus_collapse)

    def _check_focus_collapse(self):
        if self.collapsed or getattr(self, "_modal_open", False):
            return  # 日曆等子視窗開著時不收
        # 焦點仍在 widget 內（SOAP cell / 舊病歷框 / 下拉 / 按鈕）→ 不收
        if self.root.focus_get() is not None:
            return
        # 焦點離開 app；再確認滑鼠不在視窗範圍內，避免 disabled 子元件誤判
        try:
            px, py = self.root.winfo_pointerxy()
            x, y = self.root.winfo_rootx(), self.root.winfo_rooty()
            w, h = self.root.winfo_width(), self.root.winfo_height()
        except Exception:
            return
        if not (x <= px <= x + w and y <= py <= y + h):
            self.collapse()

    # ── State / UI update ─────────────────────────────────────────
    def set_state(self, state: str):
        prev = getattr(self, "state", None)
        self.state = state
        # OPPA 連線狀態變化 → 回報 Liaison（監控儀表板才知道 OPPA 機器真的活著與否，
        # 而非只看 widget 有沒有連 Liaison）。online/recording = OPPA 活；offline = 掛。
        if state != prev:
            try:
                self.liaison.report_oppa_state(state != "offline")
            except Exception:
                pass
            # 離開看診 → 套用先前延後的 widget 更新
            if state != "recording" and self._pending_widget_update:
                pend = self._pending_widget_update
                self._pending_widget_update = None
                self._apply_widget_update(*pend)
        # canvas dot color
        try:
            self.canvas.itemconfig(self._dot, fill=STATE_COLOR.get(state, "#787878"))
        except Exception:
            pass
        ip = self.current_ip or self.server_ip or "?"
        label = STATE_LABEL.get(state, state)
        try:
            self.title_state.config(text=f"OPPA {ip} — {label}")
        except Exception:
            pass

    def _update_sections_ui(self):
        for sec, w in self.section_widgets.items():
            w.config(state="normal")
            w.delete("1.0", "end")
            w.insert("1.0", self.last_sections.get(sec, ""))
            w.config(state="disabled")
        # primary_dx
        m = re.search(r"^primary_dx:\s*(.+?)$", self.last_soap_text, re.M)
        if m:
            self.last_dx = m.group(1).strip()
        try:
            self.dx_label.config(text=f"Dx: {self.last_dx}" if self.last_dx else "")
        except Exception:
            pass

    # ── WS ───────────────────────────────────────────────────────
    def on_message(self, ws, raw):
        try:
            m = json.loads(raw)
        except Exception:
            return
        t = m.get("type")
        if t == "machine_fp":
            # OPPA box 指紋到手 → 給 Liaison client，**此時才登入**（token 登入要帶指紋）。
            fp = m.get("fp") or ""
            self.liaison.set_fp(fp)
            has_cred = bool(self.liaison.token or
                            (self.liaison.clinic_id and self.liaison.secret))
            if has_cred:
                self.liaison.start()                 # 有 token/secret → 帶指紋登入
            elif fp:
                self.root.after(0, self._prompt_activation)   # 無憑證 → 啟用
            return
        if t == "version":
            # OPPA 回報版本 → 存給 Liaison client；ota_check 等 Liaison 登入成功才送
            # （太早送會掉進還沒連好的 ws）。set_oppa_version 已連則立即送。
            # idle = OPPA 距前次看診 ≥30min；非 idle 則不往下走 ota_check（連 bundle 都不傳）。
            self.liaison.set_oppa_version(m.get("content_sha") or "", self._relay_ota,
                                          m.get("version") or "", m.get("idle", True),
                                          m.get("hostname") or "", m.get("cpu") or "",
                                          m.get("mac") or "", m.get("integ") or "")
            # 同一份 version 也帶 feed_applied → 觸發 text-feed OTA 補拉（閒置才走）
            self.liaison.set_feed_state(m.get("feed_applied") or "", self._relay_feed,
                                        m.get("idle", True))
            return
        if t == "edge_ai_state":
            # 地端 AI 模式 widget-bridge：on+bridge → 在 Windows 開 0.0.0.0:port 服 /v1/*
            # 中繼到 OPPA；off → 關。診所程式打 widget 主機:port,box 維持隔離。
            self._edge_ai_token = m.get("token") or ""
            self._edge_ai_on = bool(m.get("on"))
            self.root.after(0, self._update_draw_btn)     # 地端模式才 enable「繪圖」鈕
            if m.get("on") and m.get("bridge"):
                self.root.after(0, lambda: self._edge_ai_serve_start(int(m.get("port") or 8090)))
            else:
                self.root.after(0, self._edge_ai_serve_stop)
            return
        if t == "edge_ai_response":
            # OPPA relay 回來 → 喚醒等待中的 HTTP 請求緒
            rid = m.get("id"); ev = self._edge_pending.get(rid)
            if ev:
                ev["status"] = m.get("status", 502); ev["body"] = m.get("body", "")
                ev["event"].set()
            return
        if t in ("ssh_relay_open_ok", "ssh_relay_data", "ssh_relay_close"):
            # 維修隧道走 8766：OPPA 回來的 SSH 位元組 → 轉回 Liaison maint stream
            sid = m.get("sid")
            if t == "ssh_relay_open_ok":
                self.liaison.feed_ssh_open_ok(sid)
            elif t == "ssh_relay_data":
                self.liaison.feed_ssh_data(sid, m.get("data", ""))
            else:
                self.liaison.feed_ssh_close(sid)
            return
        if t == "ota_result":
            ok = m.get("ok"); applied = m.get("applied")
            print(f"OTA: ok={ok} applied={applied} {m.get('version','')} "
                  f"{m.get('reason','')}", file=sys.stderr)
            # 回報 Liaison（離線診所更新後，你在 Liaison 就看得到結果）
            try:
                self.liaison.report_ota(m)
            except Exception:
                pass
            return
        if t == "state":
            ws_state = (m.get("state") or "").lower()
            mapped = {"recording": "recording",
                      "transcribing": "transcribing",
                      "generating": "generating",
                      "waiting_llm": "generating",
                      "idle": "idle",
                      "done": "idle"}.get(ws_state, "idle")
            self.root.after(0, lambda: self.set_state(mapped))
            # 新 SOAP 完成（DONE → idle）→ 自動刷新下拉，user 不必按 ↻
            if ws_state == "done":
                self.root.after(500, self.refresh_recent_soaps)
        elif t == "latest_soap":
            self.last_soap_text = m.get("text", "")
            self.last_dx = m.get("primary_dx", "")
            self.last_sections = parse_soap(self.last_soap_text)
            self._soap_pending = self.last_soap_text
            self._soap_event.set()
            # 存給 HIS 拉（GET /soap）+ 若設了 callback 就推回 HIS
            self._last_soap_for_his = {
                "visit_no": m.get("visit_no", "") or "",
                "soap": self.last_soap_text, "primary_dx": self.last_dx,
                "sections": self.last_sections,
                "icd": m.get("icd_suggestions", []) or []}
            self._push_soap_to_his(self._last_soap_for_his)
            # 只在沒手動選別筆時自動 update display；user 選了某筆就尊重它
            if self._selected_wav is None:
                self.root.after(0, self._update_sections_ui)
        elif t == "recent_soaps":
            items = m.get("items", []) or []
            # client-side filter today（YYYY-MM-DD prefix）
            today = datetime.now().strftime("%Y-%m-%d")
            today_items = [x for x in items
                            if (x.get("record_stopped_at") or "").startswith(today)]
            self._recent_soaps = today_items
            self.root.after(0, self._update_dropdown)
        elif t == "audit_ready":
            # 背景 V12 audit 寫完 sidecar → 重抓 recent_soaps，那筆才會 has_audit=true、
            # 才能切「V12 對照」版（widget 平常不會自己重抓）
            self.root.after(0, self.refresh_recent_soaps)
        elif t == "export_data":
            self.root.after(0, lambda: self._write_export(
                m.get("scope", "today"), m.get("rows", []) or []))
        elif t == "recon_data":
            self.root.after(0, lambda: self._write_recon(
                m.get("scope", "today"), m.get("content", "") or "", m.get("date")))
        elif t == "prior_record_state":
            self.root.after(0, lambda: self._set_prior_status(
                bool(m.get("loaded")), int(m.get("chars") or 0),
                bool(m.get("consumed"))))
        elif t == "qa_start":
            self.root.after(0, self._qa_assistant_start)
        elif t == "qa_chunk":
            d = m.get("delta", "")
            self.root.after(0, lambda d=d: self._qa_assistant_chunk(d))
        elif t == "qa_end":
            secs = m.get("seconds", 0.0)
            self.root.after(0, lambda: self._qa_assistant_end(secs))
        elif t == "qa_busy":
            self.root.after(0, self._qa_busy)
        elif t == "qa_error":
            msg = m.get("msg", "回答失敗")
            self.root.after(0, lambda: self._qa_error(msg))
        elif t == "qa_sources":
            items = m.get("items", []) or []
            self.root.after(0, lambda it=items: self._qa_sources(it))
        elif t == "draw_start":
            self.root.after(0, self._draw_on_start)
        elif t == "draw_result":
            b64 = m.get("b64", "")
            self.root.after(0, lambda b=b64: self._draw_on_result(b))
        elif t == "draw_busy":
            self.root.after(0, lambda: self._draw_on_error("看診中，繪圖暫停；看診結束後再試。"))
        elif t == "draw_error":
            self.root.after(0, lambda mm=m: self._draw_on_error(mm.get("msg", "生成失敗")))
        elif t == "doc_list":
            self.root.after(0, lambda dd=m.get("docs", []) or []: self._docs_render(dd))
        elif t == "digest_list":
            self.root.after(0, lambda ds=m.get("dates", []) or []: self._digest_on_list(ds))
        elif t == "digest_content":
            self.root.after(0, lambda mm=m: self._digest_on_content(mm))
        elif t == "feed_result":
            # text-feed pack 套用（ack）→ 若快報視窗開著，稍候刷新清單（等 digest.md 落地）
            if m.get("ok") and self._digest_alive():
                self.root.after(3500, self._digest_refresh)
        elif t == "doc_ingesting":
            self.root.after(0, lambda mm=m: self._docs_set_status(
                f"擷取+索引中：{mm.get('title','')}…（約 10–30 秒）"))
        elif t == "doc_result":
            self.root.after(0, lambda mm=m: self._docs_set_status(
                (f"✓ 已收錄「{mm.get('title','')}」（{mm.get('chunks',0)} 段）"
                 if mm.get("ok") else f"✗ {mm.get('title','')}：{mm.get('msg','失敗')}")))
        elif t == "edu_start":
            self.root.after(0, self._edu_on_start)
        elif t == "edu_chunk":
            self.root.after(0, lambda d=m.get("delta", ""): self._edu_on_chunk(d))
        elif t == "edu_end":
            self.root.after(0, lambda s=m.get("seconds", 0.0): self._edu_on_end(s))
        elif t == "edu_sources":
            self.root.after(0, lambda it=m.get("items", []) or []: self._edu_on_sources(it))
        elif t == "edu_busy":
            self.root.after(0, lambda: self._edu_on_error("看診中，衛教草稿暫停；結束後再試。"))
        elif t == "edu_error":
            self.root.after(0, lambda mm=m: self._edu_on_error(mm.get("msg", "起草失敗")))
        elif t == "nhi_audit_result":
            self.root.after(0, lambda mm=m: self._show_nhi_audit(mm))
        elif t == "homecare_note_done":
            # 長照批次：一篇 note 完成（或 error=True / med_updated 藥單補完）→ 更新清單
            self.root.after(0, lambda mm=m: self._hc_on_note_done(mm))
        elif t == "med_ocr_result":
            # 藥單 OCR 結果（ok/失敗）→ 進度 + 送下一張藥單
            self.root.after(0, lambda mm=m: self._med_on_result(mm))
        elif t == "med_upload_ready":
            pass  # server 就緒訊號；widget 直接串流不需等
        elif t == "error":
            # 批次中若收到 server error（如 ASR 失敗），記一筆並推進佇列不卡住
            if getattr(self, "_hc_running", False):
                self.root.after(0, lambda mm=m: self._hc_on_note_done(
                    {"title": "（此檔）", "note": mm.get("msg", "處理失敗"),
                     "error": True}))

    def on_open(self, ws):
        self.root.after(0, lambda: self.set_state("idle"))
        try:
            ws.send(json.dumps({"type": "client_hello", "role": "widget"}))
            # 把本機時間推給 OPPA 校正其系統鐘（離線無 NTP）— 每次連上都推一次
            ws.send(json.dumps({"type": "set_time", "epoch": time.time()}))
            # 取 OPPA box 裝置指紋（授權綁定用）
            ws.send(json.dumps({"type": "get_machine_fp"}))
            # 取 OPPA 版本（widget-relay OTA：拿去問 Liaison 有無更新）
            ws.send(json.dumps({"type": "get_version"}))
        except Exception:
            pass

    def push_time(self):
        """每小時把本機(Windows client)時間推給 OPPA 校正系統鐘（離線時鐘漂移用）。"""
        if self.ws and self.state != "offline":
            try:
                self.ws.send(json.dumps({"type": "set_time", "epoch": time.time()}))
            except Exception:
                pass
        self.root.after(3600 * 1000, self.push_time)

    def _send_get_version(self):
        """重新問 OPPA 版本（→ version 回 idle → 閒置才 ota_check）。
        定時 / Liaison push 重查都走這。看診中由 OPPA 的 idle 旗標擋下，零影響。"""
        if self.ws and self.state != "offline":
            try:
                self.ws.send(json.dumps({"type": "get_version"}))
            except Exception:
                pass

    def _ota_recheck_tick(self):
        """定時 OTA 重查（每 20 分鐘）：解決「一直在線沒重連→拿不到新版」。
        只是觸發 get_version；是否真的更新由 OPPA idle(≥30min) + 套用閘決定。"""
        self._send_get_version()
        self.root.after(20 * 60 * 1000, self._ota_recheck_tick)

    def on_close(self, ws, *_):
        self.root.after(0, lambda: self.set_state("offline"))

    def on_error(self, ws, e):
        print(f"WS error: {e}", file=sys.stderr)

    def discover(self):
        if self.server_ip:
            return self.server_ip
        return scan_oppa(self.subnet, self.port, self.scan_timeout)

    def run_ws(self):
        while True:
            ip = self.discover()
            if not ip:
                self.root.after(0, lambda: self.set_state("offline"))
                time.sleep(10)
                continue
            self.current_ip = ip
            # 維修隧道目標跟著 WS 實際連到的 OPPA IP（.ini 沒寫死 oppa_ssh_host 時）
            self.liaison.set_oppa_ip(ip)
            try:
                self.ws = websocket.WebSocketApp(
                    f"ws://{ip}:{self.port}",
                    on_message=self.on_message,
                    on_open=self.on_open,
                    on_close=self.on_close,
                    on_error=self.on_error,
                )
                self.ws.run_forever(ping_interval=30, ping_timeout=10)
            except Exception as e:
                print(f"WS exception: {e}", file=sys.stderr)
            self.root.after(0, lambda: self.set_state("offline"))
            time.sleep(3)

    # ── 舊病歷（跟逐字稿一起送生成）────────────────────────────────
    def _prior_click_paste(self, event=None):
        """點舊病歷格（空的時候）→ 自動貼入剪貼簿 + 套用。已有內容則正常點擊編輯。"""
        if self.prior_text.get("1.0", "end").strip():
            return            # 已有內容 → 正常編輯,不覆蓋
        try:
            clip = self.root.clipboard_get()
        except Exception:
            return
        if clip and clip.strip():
            self.prior_text.delete("1.0", "end")
            self.prior_text.insert("1.0", clip)
            self.apply_prior_record()
            return "break"    # 已處理,不再放游標

    def apply_prior_record(self):
        """把貼上的舊病歷送 server 存進待用槽，下次生成當背景參考帶入。"""
        if not self.ws or self.state == "offline":
            messagebox.showwarning("OPPA 舊病歷", "目前未連線，無法套用。")
            return
        txt = self.prior_text.get("1.0", "end").strip()
        try:
            self.ws.send(json.dumps({"type": "set_prior_record", "text": txt}))
        except Exception as e:
            messagebox.showerror("OPPA 舊病歷", f"套用失敗：{e}")

    def clear_prior_record(self):
        """手動清掉貼上框 + 通知 server 清空待用槽。"""
        self.prior_text.delete("1.0", "end")
        if self.ws and self.state != "offline":
            try:
                self.ws.send(json.dumps({"type": "set_prior_record", "text": ""}))
            except Exception:
                pass
        self._set_prior_status(False, 0, False)

    def _set_prior_status(self, loaded: bool, chars: int, consumed: bool):
        """更新舊病歷狀態列；consumed=生成已帶入 → 自動清空貼上框。"""
        if consumed:
            self.prior_text.delete("1.0", "end")
            self.prior_status.config(text="✓ 已帶入並清空", fg="#8b949e")
        elif loaded:
            self.prior_status.config(text=f"✓ 已套用 {chars} 字", fg="#3fb950")
        else:
            self.prior_status.config(text="")

    # ── 今日防核刪審查（Layer 1）────────────────────────────────────
    def _edge_ai_serve_start(self, port: int):
        """地端 AI 模式(widget-bridge)：在 Windows 開 0.0.0.0:port 服 OpenAI 相容 /v1/*，
        每個請求驗 Bearer token 後經主 ws 中繼到 OPPA(edge_ai_request)、等 edge_ai_response。
        診所程式打『widget 主機:port』,OPPA box 維持點對點隔離、不對外開埠。"""
        if self._edge_srv:
            return
        try:
            from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
        except Exception as e:
            print(f"http.server 不可用(需重build .exe)，地端AI bridge 停用：{e}", file=sys.stderr)
            return
        widget = self

        class _E(BaseHTTPRequestHandler):
            def _send(self, code, body_bytes):
                self.send_response(code)
                self.send_header("Content-Type", "application/json")
                self.send_header("Content-Length", str(len(body_bytes)))
                self.end_headers(); self.wfile.write(body_bytes)

            def _relay(self):
                if self.path == "/health":
                    return self._send(200, b'{"status":"ok"}')
                if not widget.ws or widget.state == "offline":
                    return self._send(503, b'{"error":"OPPA offline"}')
                auth = self.headers.get("Authorization", "")
                if auth != f"Bearer {widget._edge_ai_token}":
                    return self._send(401, b'{"error":"bad token"}')
                try:
                    n = int(self.headers.get("Content-Length", 0))
                    body = self.rfile.read(n).decode("utf-8", "replace") if n else ""
                except Exception as e:
                    return self._send(400, json.dumps({"error": str(e)}).encode())
                widget._edge_seq += 1
                rid = f"e{widget._edge_seq}"
                ev = {"event": threading.Event(), "status": 502, "body": ""}
                widget._edge_pending[rid] = ev
                try:
                    with widget._ws_send_lock:
                        widget.ws.send(json.dumps({"type": "edge_ai_request", "id": rid,
                            "path": self.path, "method": self.command,
                            "token": widget._edge_ai_token, "body": body}))
                except Exception as e:
                    widget._edge_pending.pop(rid, None)
                    return self._send(502, json.dumps({"error": f"relay send: {e}"}).encode())
                ok = ev["event"].wait(timeout=300)
                widget._edge_pending.pop(rid, None)
                if not ok:
                    return self._send(504, b'{"error":"OPPA relay timeout"}')
                self._send(ev["status"], (ev["body"] or "").encode("utf-8"))

            def do_POST(self): self._relay()
            def do_GET(self): self._relay()
            def log_message(self, *a): pass

        try:
            srv = ThreadingHTTPServer(("0.0.0.0", port), _E)
            threading.Thread(target=srv.serve_forever, daemon=True).start()
            self._edge_srv = srv
            print(f"Edge-AI bridge on 0.0.0.0:{port}", file=sys.stderr)
        except Exception as e:
            print(f"Edge-AI serve start failed: {e}", file=sys.stderr)

    def _edge_ai_serve_stop(self):
        srv = self._edge_srv
        if srv:
            try:
                srv.shutdown()
            except Exception:
                pass
        self._edge_srv = None
        self._edge_ai_token = ""
        print("Edge-AI bridge stopped", file=sys.stderr)

    def _start_local_llm_api(self):
        """第一方內部本地 LLM 通道（always-on）：widget 在 **127.0.0.1** 開 OpenAI 相容端點
        給 OPPA 自家 Windows 程式用 —— 免付費/kiosk 閘，但看診中由 OPPA 端讓路(503 busy)。
        token 寫本地檔(.oppa-local-llm.token)供自家程式讀。中繼帶 internal=True。
        埠 OPPA_LOCAL_LLM_PORT（預設 8091，0=停用）。"""
        import os as _os
        try:
            port = int(_os.environ.get("OPPA_LOCAL_LLM_PORT", "8091"))
        except Exception:
            port = 8091
        if port <= 0 or self._local_llm_srv:
            return
        self._local_llm_token = self._local_llm_token or os.urandom(18).hex()
        try:
            tokfile = os.path.join(os.path.expanduser("~"), ".oppa-local-llm.token")
            with open(tokfile, "w") as f:
                f.write(self._local_llm_token)
        except Exception:
            pass
        try:
            from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
        except Exception as e:
            print(f"http.server 不可用(需重build .exe)，本地 LLM 通道停用：{e}", file=sys.stderr)
            return
        widget = self

        class _L(BaseHTTPRequestHandler):
            def _send(self, code, b):
                self.send_response(code); self.send_header("Content-Type", "application/json")
                self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)

            def _relay(self):
                if self.path == "/health":
                    return self._send(200, b'{"status":"ok"}')
                if not widget.ws or widget.state == "offline":
                    return self._send(503, b'{"error":"OPPA offline"}')
                if self.headers.get("Authorization", "") != f"Bearer {widget._local_llm_token}":
                    return self._send(401, b'{"error":"bad token"}')
                try:
                    n = int(self.headers.get("Content-Length", 0))
                    body = self.rfile.read(n).decode("utf-8", "replace") if n else ""
                except Exception as e:
                    return self._send(400, json.dumps({"error": str(e)}).encode())
                widget._edge_seq += 1
                rid = f"i{widget._edge_seq}"
                ev = {"event": threading.Event(), "status": 502, "body": ""}
                widget._edge_pending[rid] = ev
                try:
                    with widget._ws_send_lock:
                        widget.ws.send(json.dumps({"type": "edge_ai_request", "id": rid,
                            "internal": True, "path": self.path, "method": self.command,
                            "body": body}))
                except Exception as e:
                    widget._edge_pending.pop(rid, None)
                    return self._send(502, json.dumps({"error": f"relay: {e}"}).encode())
                ok = ev["event"].wait(timeout=300)
                widget._edge_pending.pop(rid, None)
                if not ok:
                    return self._send(504, b'{"error":"timeout"}')
                self._send(ev["status"], (ev["body"] or "").encode("utf-8"))

            def do_POST(self): self._relay()
            def do_GET(self): self._relay()
            def log_message(self, *a): pass

        try:
            srv = ThreadingHTTPServer(("127.0.0.1", port), _L)
            threading.Thread(target=srv.serve_forever, daemon=True).start()
            self._local_llm_srv = srv
            print(f"Local-LLM (first-party) on 127.0.0.1:{port}", file=sys.stderr)
        except Exception as e:
            print(f"Local-LLM start failed: {e}", file=sys.stderr)

    def _start_his_api(self):
        """HIS 整合本機 API（127.0.0.1，HIS 與 widget 同台 Windows）：
          POST /visit  body={"visit_no":"A12"}  → 推診號給 OPPA（kiosk 顯示 + 待錄音）
          GET  /soap   ?visit=A12               → 取最近 SOAP（HIS 拉，選用）
        HIS 點病人時呼叫 /visit；OPPA 生成後 widget 也可 POST 回 HIS callback（見 _push_soap_to_his）。
        埠 OPPA_HIS_API_PORT。**預設 0=停用**（要設成正埠如 8780 才開通；目前各診所未開）。"""
        import os as _os
        try:
            port = int(_os.environ.get("OPPA_HIS_API_PORT", "0"))
        except Exception:
            port = 0
        if port <= 0:
            return    # 預設不開 HIS API
        try:
            from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
        except Exception as e:
            print(f"http.server 不可用(需重build .exe)，HIS API 停用：{e}", file=sys.stderr)
            return
        widget = self

        class _H(BaseHTTPRequestHandler):
            def _send(self, code, obj):
                b = json.dumps(obj).encode()
                self.send_response(code)
                self.send_header("Content-Type", "application/json")
                self.send_header("Content-Length", str(len(b)))
                self.end_headers()
                self.wfile.write(b)

            def do_POST(self):
                from urllib.parse import urlparse
                if urlparse(self.path).path != "/visit":
                    self._send(404, {"ok": False}); return
                try:
                    n = int(self.headers.get("Content-Length", 0))
                    body = json.loads(self.rfile.read(n) or b"{}")
                    vn = str(body.get("visit_no") or "").strip()
                except Exception as e:
                    self._send(400, {"ok": False, "error": str(e)}); return
                if not vn:
                    self._send(400, {"ok": False, "error": "missing visit_no"}); return
                # 轉給 OPPA：設診號（kiosk 會收 visit_no_set 廣播顯示）
                widget.root.after(0, lambda: widget._his_set_visit(vn))
                self._send(200, {"ok": True, "visit_no": vn})

            def do_GET(self):
                from urllib.parse import urlparse, parse_qs
                if urlparse(self.path).path != "/soap":
                    self._send(404, {"ok": False}); return
                q = parse_qs(urlparse(self.path).query)
                want = (q.get("visit", [""])[0]).strip()
                s = getattr(widget, "_last_soap_for_his", None)
                if s and (not want or s.get("visit_no") == want):
                    self._send(200, {"ok": True, **s})
                else:
                    self._send(404, {"ok": False, "reason": "no_soap"})

            def log_message(self, *a):
                pass

        try:
            srv = ThreadingHTTPServer(("127.0.0.1", port), _H)
            threading.Thread(target=srv.serve_forever, daemon=True).start()
            print(f"HIS API on 127.0.0.1:{port}", file=sys.stderr)
        except Exception as e:
            print(f"HIS API start failed: {e}", file=sys.stderr)

    def _push_soap_to_his(self, soap: dict):
        """OPPA 生成 SOAP → 若設了 HIS callback URL（OPPA_HIS_CALLBACK_URL）就 POST 回 HIS
        （帶診號 = HIS 推來的 key，HIS 據此貼到對的病人）。沒設則靠 HIS 自己 GET /soap 拉。"""
        import os as _os
        url = _os.environ.get("OPPA_HIS_CALLBACK_URL", "").strip()
        if not url or not soap.get("visit_no"):
            return
        def _go():
            try:
                import urllib.request
                data = json.dumps(soap).encode()
                req = urllib.request.Request(url, data=data,
                                             headers={"Content-Type": "application/json"})
                urllib.request.urlopen(req, timeout=8).read()
                print(f"SOAP pushed to HIS ({soap.get('visit_no')})", file=sys.stderr)
            except Exception as e:
                print(f"HIS push failed: {e}", file=sys.stderr)
        threading.Thread(target=_go, daemon=True).start()

    def _his_set_visit(self, visit_no: str):
        """HIS 推來的診號 → 送 OPPA set_visit_no（kiosk 廣播顯示 + 待醫師按開始）。"""
        if not self.ws or self.state == "offline":
            return
        try:
            self.ws.send(json.dumps({"type": "set_visit_no",
                                     "visit_no": visit_no, "source": "his"}))
        except Exception as e:
            print(f"his_set_visit failed: {e}", file=sys.stderr)

    def open_homecare_window(self):
        """長照分頁：到宅 USB 錄音筆一趟錄好幾家（檔名為日期時間）→ 回診所插上電腦
        → 免輸診號、一次選多檔 → OPPA 逐檔轉錄＋生「在宅醫療科 Clinical Note」，
        每篇以錄音日期時間為標題，完成後累積在下方清單供各自複製。單例。"""
        if self._homecare_win is not None and tk.Toplevel.winfo_exists(self._homecare_win):
            self._homecare_win.deiconify(); self._homecare_win.lift()
            return
        if not hasattr(self, "_hc_notes"):
            self._hc_notes = []            # [{title, wav, note, error}]，跨開關視窗保留
        win = tk.Toplevel(self.root)
        self._homecare_win = win
        win.title("OPPA 長照（批次生 note）")
        win.configure(bg="#0d1117")
        win.geometry("560x620")
        win.attributes("-topmost", True)
        win.protocol("WM_DELETE_WINDOW",
                     lambda: (win.destroy(), setattr(self, "_homecare_win", None)))

        tk.Label(win, text="🏠 長照到宅 — 批次生 Clinical Note", bg="#0d1117",
                 fg="#ffd6a5", font=("Segoe UI", 13, "bold")).pack(pady=(12, 2))
        tk.Label(win, text="插上錄音筆 → 免輸診號、一次選多個錄音檔 → 逐檔生 note，"
                           "標題＝錄音日期時間",
                 bg="#0d1117", fg="#8b949e", font=("Segoe UI", 8),
                 wraplength=520, justify="center").pack()

        self._hc_btn = tk.Button(win, text="📁 選多個錄音檔並開始（多家批次）",
                                 command=self._homecare_pick,
                                 bg="#1f6feb", fg="#fff", borderwidth=0, cursor="hand2",
                                 padx=14, pady=8, font=("Segoe UI", 11, "bold"),
                                 activebackground="#388bfd")
        self._hc_btn.pack(pady=(12, 4))
        # 一份到宅：一次選 1 音檔 + 幾張藥單照片 → 自動先生 note 再把藥單併進用藥區
        self._hc_combo_btn = tk.Button(win, text="🏠＋📷 一份到宅（音檔＋藥單一起）",
                                       command=self._combo_pick,
                                       bg="#238636", fg="#fff", borderwidth=0, cursor="hand2",
                                       padx=14, pady=8, font=("Segoe UI", 11, "bold"),
                                       activebackground="#2ea043")
        self._hc_combo_btn.pack(pady=(0, 6))
        self._hc_status = tk.Label(win, text="", bg="#0d1117", fg="#8b949e",
                                   font=("Segoe UI", 9), wraplength=520, justify="left")
        self._hc_status.pack(fill="x", padx=14)

        # 結果清單：每篇 note（標題＝日期時間）累積於此，可捲動＋複製
        resfrm = tk.Frame(win, bg="#0d1117"); resfrm.pack(fill="both", expand=True,
                                                          padx=10, pady=(6, 4))
        sb = tk.Scrollbar(resfrm); sb.pack(side="right", fill="y")
        self._hc_results = tk.Text(resfrm, wrap="word", bg="#161b22", fg="#e6edf3",
                                   font=("Consolas", 9), yscrollcommand=sb.set,
                                   insertbackground="#fff", height=20)
        self._hc_results.pack(side="left", fill="both", expand=True)
        sb.config(command=self._hc_results.yview)
        self._hc_results.tag_configure("title", foreground="#ffd6a5",
                                       font=("Segoe UI", 10, "bold"))
        self._hc_results.tag_configure("err", foreground="#f85149")

        # 藥單 OCR 列：選一篇 note（下拉，標題＝日期時間）+ 藥單照片 → 補進用藥區
        medrow = tk.Frame(win, bg="#0d1117"); medrow.pack(fill="x", padx=10, pady=(0, 2))
        tk.Label(medrow, text="📷 藥單 OCR →", bg="#0d1117", fg="#ffd6a5",
                 font=("Segoe UI", 9, "bold")).pack(side="left")
        self._hc_target_var = tk.StringVar(value="（尚無 note）")
        self._hc_target_menu = tk.OptionMenu(medrow, self._hc_target_var, "（尚無 note）")
        self._hc_target_menu.config(bg="#161b22", fg="#e6edf3", highlightthickness=0,
                                    font=("Segoe UI", 8), width=16)
        self._hc_target_menu.pack(side="left", padx=4)
        tk.Button(medrow, text="選藥單照片並整合", command=self._med_ocr_pick,
                  bg="#6e40c9", fg="#fff", borderwidth=0, cursor="hand2",
                  padx=10, pady=4, font=("Segoe UI", 9, "bold"),
                  activebackground="#8957e5").pack(side="left", padx=4)

        btnrow = tk.Frame(win, bg="#0d1117"); btnrow.pack(fill="x", padx=10, pady=(0, 10))
        tk.Button(btnrow, text="全選複製", command=self._hc_copy_all,
                  bg="#21262d", fg="#e6edf3", borderwidth=0, cursor="hand2",
                  padx=10, pady=4, font=("Segoe UI", 9)).pack(side="left")
        tk.Button(btnrow, text="清空", command=self._hc_clear_results,
                  bg="#21262d", fg="#e6edf3", borderwidth=0, cursor="hand2",
                  padx=10, pady=4, font=("Segoe UI", 9)).pack(side="left", padx=6)
        self._hc_render_results()   # 重開視窗時把既有 note 畫回來

    def _hc_set_status(self, text, color="#8b949e"):
        try:
            self._hc_status.config(text=text, fg=color)
        except Exception:
            pass

    def _hc_copy_all(self):
        try:
            txt = self._hc_results.get("1.0", "end-1c")
            self.root.clipboard_clear(); self.root.clipboard_append(txt)
            self._hc_set_status("已複製全部 note 到剪貼簿。", "#3fb950")
        except Exception:
            pass

    def _hc_clear_results(self):
        self._hc_notes = []
        self._hc_render_results()

    def _hc_render_results(self):
        """從 self._hc_notes 重畫結果清單 + 刷新藥單目標下拉。（note 更新用整份重畫。）"""
        try:
            self._hc_results.delete("1.0", "end")
            for n in self._hc_notes:
                is_err = bool(n.get("error"))
                self._hc_results.insert("end", f"\n{n.get('title','')}\n",
                                        ("err" if is_err else "title",))
                self._hc_results.insert("end", (n.get("note") or "") + "\n",
                                        ("err",) if is_err else ())
            self._hc_results.see("end")
        except Exception:
            pass
        self._hc_refresh_target_menu()

    def _hc_refresh_target_menu(self):
        """藥單 OCR 目標下拉：列出可補藥單的 note（有 wav、非錯誤）標題。"""
        try:
            menu = self._hc_target_menu["menu"]; menu.delete(0, "end")
            titles = [n["title"] for n in self._hc_notes
                      if n.get("wav") and not n.get("error")]
            if not titles:
                self._hc_target_var.set("（尚無 note）")
                menu.add_command(label="（尚無 note）",
                                 command=lambda: self._hc_target_var.set("（尚無 note）"))
                return
            cur = self._hc_target_var.get()
            for t in titles:
                menu.add_command(label=t, command=lambda v=t: self._hc_target_var.set(v))
            if cur not in titles:
                self._hc_target_var.set(titles[-1])   # 預設最後一篇
        except Exception:
            pass

    # 一份到宅：一次選 1 音檔 + N 藥單照片 → 音檔生 note → 照片自動併進該 note 用藥區。
    _AUDIO_EXT = {".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".wma"}
    _IMG_EXT = {".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".webp"}

    def _combo_pick(self):
        if not self.ws or self.state == "offline":
            self._hc_set_status("未連線 OPPA,無法上傳。", "#f85149"); return
        if getattr(self, "_hc_running", False) or getattr(self, "_combo_pending", False) \
                or getattr(self, "_med_queue", None):
            self._hc_set_status("處理中，請等目前這份跑完。", "#f0883e"); return
        paths = filedialog.askopenfilenames(
            title="選 1 個音檔 + 藥單照片（可多選）",
            filetypes=[("音檔＋影像", "*.wav *.mp3 *.m4a *.aac *.flac *.ogg *.wma "
                        "*.jpg *.jpeg *.png *.bmp *.tif *.tiff *.webp"),
                       ("所有檔案", "*.*")])
        if not paths:
            return
        audios = [p for p in paths if os.path.splitext(p)[1].lower() in self._AUDIO_EXT]
        imgs = [p for p in paths
                if os.path.splitext(p)[1].lower() in self._IMG_EXT
                and (os.path.getsize(p) if os.path.exists(p) else 0) <= 30 * 1024 * 1024]
        if not audios:
            self._hc_set_status("要選 1 個音檔（一份到宅＝1 段錄音＋藥單照片）。", "#f0883e"); return
        if len(audios) > 1:
            self._hc_set_status("一份到宅只放 1 個音檔；多家請用上面的批次鈕。", "#f0883e"); return
        try:
            if os.path.getsize(audios[0]) > 300 * 1024 * 1024:
                self._hc_set_status("音檔過大（>300MB）。", "#f0883e"); return
        except Exception as e:
            self._hc_set_status(f"讀檔失敗: {e}", "#f85149"); return
        # 記住待補的照片；先送音檔生 note，note 回來(拿到 wav)再自動送照片
        self._combo = {"photos": list(imgs)}
        self._combo_pending = True
        self._hc_set_status(f"① 轉錄音檔生 note 中…（{len(imgs)} 張藥單待補）", "#a5d6ff")
        threading.Thread(target=self._hc_upload_one, args=(audios[0],), daemon=True).start()

    def _homecare_pick(self):
        if not self.ws or self.state == "offline":
            self._hc_set_status("未連線 OPPA,無法上傳。", "#f85149"); return
        if getattr(self, "_hc_running", False):
            self._hc_set_status("批次處理中，請等目前這批跑完。", "#f0883e"); return
        paths = filedialog.askopenfilenames(
            title="選錄音檔（可多選）",
            filetypes=[("音檔", "*.wav *.mp3 *.m4a *.aac *.flac *.ogg *.wma"),
                       ("所有檔案", "*.*")])
        if not paths:
            return
        good = []
        for p in paths:
            try:
                if os.path.getsize(p) > 300 * 1024 * 1024:
                    continue
                good.append(p)
            except Exception:
                pass
        if not good:
            self._hc_set_status("沒有可上傳的檔（空/過大>300MB）。", "#f0883e"); return
        # 依檔名排序（多為日期時間），批次逐檔跑
        self._hc_queue = sorted(good, key=lambda x: os.path.basename(x).lower())
        self._hc_total = len(self._hc_queue)
        self._hc_done = 0
        self._hc_running = True
        try:
            self._hc_btn.config(state="disabled")
        except Exception:
            pass
        self._hc_start_next()

    def _hc_start_next(self):
        """佇列驅動：送下一個檔；空佇列 → 收尾。由主緒呼叫。"""
        q = getattr(self, "_hc_queue", [])
        if not q:
            self._hc_running = False
            try:
                self._hc_btn.config(state="normal")
            except Exception:
                pass
            self._hc_set_status(f"✓ 批次完成，共 {getattr(self,'_hc_done',0)}/"
                                f"{getattr(self,'_hc_total',0)} 篇。", "#3fb950")
            return
        path = q.pop(0)
        n = self._hc_done + 1
        self._hc_set_status(f"處理中 {n}/{self._hc_total}：{os.path.basename(path)} "
                            f"（轉錄＋生成，請稍候）", "#a5d6ff")
        threading.Thread(target=self._hc_upload_one, args=(path,), daemon=True).start()

    def _hc_upload_one(self, path):
        """背景緒分塊送單一檔（mode=homecare、無診號）。送完等 homecare_note_done。"""
        try:
            with open(path, "rb") as f:
                data = f.read()
            mtime = os.path.getmtime(path)
            self.ws.send(json.dumps({"type": "upload_start", "mode": "homecare",
                                     "name": os.path.basename(path),
                                     "size": len(data), "mtime": mtime}))
            CH = 256 * 1024
            for i in range(0, len(data), CH):
                self.ws.send(json.dumps({"type": "upload_chunk",
                    "data": base64.b64encode(data[i:i + CH]).decode()}))
            self.ws.send(json.dumps({"type": "upload_end"}))
        except Exception as e:
            # 送檔就失敗 → 主緒記一筆錯誤並跳下一檔
            self.root.after(0, lambda e=e, p=path: self._hc_on_note_done(
                {"title": os.path.basename(p), "note": f"上傳失敗: {e}", "error": True}))

    def _hc_on_note_done(self, m):
        """收到一篇 note（新生成 / 錯誤 / 藥單 OCR 更新）→ 更新清單。主緒呼叫。
        med_updated=True 是同 wav 的用藥區補完版 → 就地覆寫、不推進批次佇列。"""
        wav = m.get("wav") or ""
        title = m.get("title") or ""
        note = m.get("note") or ""
        is_err = bool(m.get("error"))
        med_updated = bool(m.get("med_updated"))
        if not hasattr(self, "_hc_notes"):
            self._hc_notes = []
        # 同 wav 已在清單 → 就地更新（藥單補完 / 重生）；否則新增一筆
        idx = next((i for i, n in enumerate(self._hc_notes)
                    if wav and n.get("wav") == wav), None)
        if idx is not None:
            self._hc_notes[idx].update(title=title, note=note, error=is_err)
        else:
            self._hc_notes.append({"wav": wav, "title": title,
                                   "note": note, "error": is_err})
        self._hc_render_results()
        if med_updated:
            self._hc_set_status(f"✓ 藥單已整合進「{title}」用藥區。", "#3fb950")
            return
        # 一份到宅：音檔 note 剛生好 → 接著自動把藥單照片併進這篇（同一份 note）
        if getattr(self, "_combo_pending", False):
            self._combo_pending = False
            if is_err or not wav:
                self._hc_set_status("音檔沒生出 note，藥單未上傳。", "#f0883e"); return
            photos = (getattr(self, "_combo", None) or {}).get("photos") or []
            if photos:
                self._med_wav = wav
                self._med_queue = list(photos)
                self._med_total = len(photos); self._med_done = 0
                self._hc_set_status(f"② note 完成，補 {len(photos)} 張藥單進用藥區…", "#a5d6ff")
                self._med_start_next()
            else:
                self._hc_set_status(f"✓ note 完成（無藥單照片）：{title}", "#3fb950")
            return
        # 批次生成路徑 → 推進下一檔
        if getattr(self, "_hc_running", False):
            self._hc_done += 1
            self._hc_start_next()

    def _med_ocr_pick(self):
        """選一篇 note（下拉）+ 藥單照片（可多張）→ 序列上傳 → server OCR 補用藥區。"""
        if not self.ws or self.state == "offline":
            self._hc_set_status("未連線 OPPA,無法上傳藥單。", "#f85149"); return
        if getattr(self, "_hc_running", False):
            self._hc_set_status("批次生成中，請等跑完再補藥單。", "#f0883e"); return
        title = self._hc_target_var.get()
        target = next((n for n in getattr(self, "_hc_notes", [])
                       if n.get("title") == title and n.get("wav")
                       and not n.get("error")), None)
        if not target:
            self._hc_set_status("請先選一篇已生成的 note 當藥單對象。", "#f0883e"); return
        imgs = filedialog.askopenfilenames(
            title=f"為「{title}」選藥單照片（可多張）",
            filetypes=[("影像", "*.jpg *.jpeg *.png *.bmp *.tif *.tiff *.webp"),
                       ("所有檔案", "*.*")])
        if not imgs:
            return
        good = [p for p in imgs
                if (os.path.getsize(p) if os.path.exists(p) else 0) <= 30 * 1024 * 1024]
        if not good:
            self._hc_set_status("沒有可用影像（空/過大>30MB）。", "#f0883e"); return
        # 序列處理：一次送一張，等 server 回 med_ocr_result 再送下一張（server 一次只 OCR 一張）
        self._med_wav = target["wav"]
        self._med_queue = list(good)
        self._med_total = len(good)
        self._med_done = 0
        self._med_start_next(title)

    def _med_start_next(self, title=""):
        q = getattr(self, "_med_queue", [])
        if not q:
            self._hc_set_status(f"✓ 藥單處理完成（{getattr(self,'_med_done',0)}/"
                                f"{getattr(self,'_med_total',0)} 張）。", "#3fb950")
            return
        path = q.pop(0)
        self._hc_set_status(f"藥單 OCR {self._med_done + 1}/{self._med_total}："
                            f"{os.path.basename(path)}（讀圖＋整合，請稍候）", "#a5d6ff")
        threading.Thread(target=self._med_upload_one,
                         args=(self._med_wav, path), daemon=True).start()

    def _med_upload_one(self, wav, path):
        try:
            with open(path, "rb") as f:
                data = f.read()
            self.ws.send(json.dumps({"type": "med_upload_start", "wav": wav,
                                     "name": os.path.basename(path), "size": len(data)}))
            CH = 256 * 1024
            for i in range(0, len(data), CH):
                self.ws.send(json.dumps({"type": "med_upload_chunk",
                    "data": base64.b64encode(data[i:i + CH]).decode()}))
            self.ws.send(json.dumps({"type": "med_upload_end"}))
        except Exception as e:
            self.root.after(0, lambda e=e: self._med_on_result(
                {"ok": False, "msg": f"藥單上傳失敗: {e}"}))

    def _med_on_result(self, m):
        """收到 server med_ocr_result → 進度 + 送下一張。（note 內容由 homecare_note_done 更新。）"""
        self._med_done = getattr(self, "_med_done", 0) + 1
        if not m.get("ok"):
            self._hc_set_status(f"⚠ 藥單第 {self._med_done} 張：{m.get('msg','失敗')}",
                                "#f0883e")
        self._med_start_next()

    def request_nhi_audit(self):
        if not self.ws or self.state == "offline":
            messagebox.showwarning("防核刪審查", "目前未連線，無法檢查。")
            return
        try:
            self.ws.send(json.dumps({"type": "nhi_audit"}))   # date=None → 今天
        except Exception as e:
            messagebox.showerror("防核刪審查", f"請求失敗：{e}")

    def _show_nhi_audit(self, m: dict):
        date = m.get("date", "")
        scanned = m.get("scanned", 0)
        items = m.get("items", []) or []
        err = m.get("error")
        win = tk.Toplevel(self.root)
        win.title("今日防核刪審查")
        win.configure(bg="#0d1117")
        win.geometry("560x460")
        win.attributes("-topmost", True)
        hdr = tk.Label(win, bg="#0d1117", fg="#e3b341", font=("Segoe UI", 11, "bold"),
                       text=f"🛡️ {date} 防核刪審查　掃描 {scanned} 筆 · 風險 {len(items)} 筆")
        hdr.pack(anchor="w", padx=10, pady=(8, 4))
        wrap = tk.Frame(win, bg="#0d1117"); wrap.pack(fill="both", expand=True, padx=10, pady=(0, 10))
        sb = tk.Scrollbar(wrap); sb.pack(side="right", fill="y")
        txt = tk.Text(wrap, wrap="word", bg="#161b22", fg="#c9d1d9", relief="flat",
                      padx=8, pady=6, font=("Segoe UI", 10), highlightthickness=0,
                      yscrollcommand=sb.set)
        txt.pack(side="left", fill="both", expand=True); sb.config(command=txt.yview)
        txt.tag_config("risk", foreground="#f0883e", font=("Segoe UI", 10, "bold"))
        txt.tag_config("dim", foreground="#8b949e", font=("Segoe UI", 9))
        if err:
            txt.insert("end", f"檢查失敗：{err}\n")
        elif not items:
            txt.insert("end", "✓ 今日無核刪風險項目（或尚無含維生素之處方）。\n", "dim")
        else:
            for it in items:
                dx = it.get("primary_dx") or "(無診斷)"
                txt.insert("end", f"⚠️ {dx}　開立「{it.get('drug','')}」\n", "risk")
                txt.insert("end", f"　風險：{it.get('reason','')}\n", "dim")
                if it.get("needed"):
                    txt.insert("end", f"　{it['needed']}\n", "dim")
                if it.get("nhi_source"):
                    txt.insert("end", f"　依據：{it['nhi_source']}\n\n", "dim")
        txt.config(state="disabled")

    # ── 繪圖視窗（獨立 Toplevel，僅地端 AI 模式可用）──────────────────────
    def _update_draw_btn(self):
        """地端 AI 模式 → enable「繪圖」鈕;否則 disable。"""
        if self._draw_btn is None:
            return
        self._draw_btn.config(state="normal" if self._edge_ai_on else "disabled")
        # 模式關掉時若繪圖視窗開著 → 提示並鎖生成
        if not self._edge_ai_on and self._draw_win is not None \
                and tk.Toplevel.winfo_exists(self._draw_win):
            self._draw_set_status("已離開地端 AI 模式，繪圖暫停。")
            if getattr(self, "_draw_gen_btn", None) is not None:
                self._draw_gen_btn.config(state="disabled")

    def open_draw_window(self):
        """開（或聚焦）繪圖視窗。需地端 AI 模式;單例。"""
        if not self._edge_ai_on:
            messagebox.showinfo("OPPA 繪圖",
                                "請先在 kiosk 點「地端 AI 模式」後再使用繪圖。")
            return
        if self._draw_win is not None and tk.Toplevel.winfo_exists(self._draw_win):
            self._draw_win.deiconify(); self._draw_win.lift(); return
        win = tk.Toplevel(self.root)
        self._draw_win = win
        win.title("OPPA 繪圖")
        win.configure(bg="#0d1117")
        win.geometry("560x680")
        win.minsize(420, 520)
        win.attributes("-topmost", True)
        win.protocol("WM_DELETE_WINDOW", self._close_draw_window)

        hdr = tk.Frame(win, bg="#0d1117")
        hdr.pack(fill="x", padx=8, pady=(6, 2))
        tk.Label(hdr, text="OPPA 地端繪圖", bg="#0d1117", fg="#d2a8ff",
                 font=("Segoe UI", 11, "bold")).pack(side="left")

        in_frame = tk.Frame(win, bg="#0d1117")
        in_frame.pack(fill="x", padx=8, pady=(4, 4))
        self._draw_input = tk.Text(in_frame, height=3, wrap="word", bg="#0d1117",
                                   fg="#c9d1d9", relief="flat", padx=6, pady=4,
                                   insertbackground="#c9d1d9", font=("Segoe UI", 10),
                                   highlightbackground="#30363d", highlightthickness=1)
        self._draw_input.pack(side="left", fill="both", expand=True)
        self._draw_input.bind("<Return>", self._draw_on_return)
        self._draw_input.bind("<Shift-Return>", lambda e: None)
        self._draw_gen_btn = tk.Button(in_frame, text="生成", command=self._draw_send,
                                       bg="#8957e5", fg="white", borderwidth=0,
                                       cursor="hand2", padx=12, font=("Segoe UI", 10, "bold"),
                                       activebackground="#a371f7")
        self._draw_gen_btn.pack(side="right", fill="y", padx=(6, 0))

        self._draw_status = tk.Label(win, text="輸入中文描述（例：戴帽子的柴犬，水彩風），按生成。",
                                     bg="#0d1117", fg="#8b949e", font=("Segoe UI", 9),
                                     wraplength=520, justify="left", anchor="w")
        self._draw_status.pack(fill="x", padx=10, pady=(0, 4))
        # 生圖進度條（不確定型跑馬燈；sd-server 無真實 % → 顯示「有在動」+ 經過秒數）。
        # 初始不 pack，_draw_start_progress 才顯示。
        self._draw_pbar = ttk.Progressbar(win, mode="indeterminate", length=520)

        img_wrap = tk.Frame(win, bg="#161b22", highlightbackground="#30363d",
                            highlightthickness=1)
        img_wrap.pack(fill="both", expand=True, padx=8, pady=4)
        self._draw_canvas = tk.Label(img_wrap, bg="#161b22", text="（尚未生成圖片）",
                                     fg="#484f58", font=("Segoe UI", 10))
        self._draw_canvas.pack(fill="both", expand=True)

        self._draw_dl_btn = tk.Button(win, text="⬇ 下載", command=self._draw_download,
                                      bg="#238636", fg="white", borderwidth=0,
                                      cursor="hand2", padx=12, pady=4,
                                      font=("Segoe UI", 10, "bold"),
                                      activebackground="#2ea043", state="disabled")
        self._draw_dl_btn.pack(pady=(2, 8))
        self._draw_input.focus_set()

    def _close_draw_window(self):
        if self._draw_win is not None:
            self._draw_win.destroy()
        self._draw_win = None
        self._draw_img = None       # 釋放 PhotoImage ref

    def _draw_alive(self) -> bool:
        return self._draw_win is not None and tk.Toplevel.winfo_exists(self._draw_win)

    def _draw_set_status(self, text: str):
        if self._draw_alive():
            self._draw_status.config(text=text)

    def _draw_on_return(self, event):
        self._draw_send()
        return "break"

    def _draw_send(self):
        if self._draw_waiting or not self._draw_alive():
            return
        if not self._edge_ai_on:
            self._draw_set_status("請先在 kiosk 進入地端 AI 模式。")
            return
        prompt = self._draw_input.get("1.0", "end").strip()
        if not prompt:
            return
        if not self.ws or self.state == "offline":
            self._draw_set_status("（目前未連線 OPPA，無法繪圖）")
            return
        self._draw_waiting = True
        self._draw_gen_btn.config(state="disabled", text="…")
        self._draw_start_progress()
        try:
            self.ws.send(json.dumps({"type": "draw_request", "prompt": prompt}))
        except Exception as e:
            self._draw_stop_progress()
            self._draw_waiting = False
            self._draw_gen_btn.config(state="normal", text="生成")
            self._draw_set_status(f"（送出失敗：{e}）")

    # ── 生圖進度（不確定型 + 經過秒數）────────────────────────────
    def _draw_start_progress(self):
        if not self._draw_alive():
            return
        self._draw_t0 = time.time()
        if self._draw_pbar is not None:
            self._draw_pbar.pack(fill="x", padx=10, pady=(0, 4))
            self._draw_pbar.start(12)      # 每 12ms 推進跑馬燈
        self._draw_tick()

    def _draw_tick(self):
        if not self._draw_waiting or not self._draw_alive():
            return
        el = int(time.time() - self._draw_t0)
        self._draw_set_status(f"🎨 生成中… {el}s（首張需載入繪圖模型，約 30–60 秒）")
        self._draw_tick_job = self.root.after(500, self._draw_tick)

    def _draw_stop_progress(self):
        job = self._draw_tick_job
        if job is not None:
            try: self.root.after_cancel(job)
            except Exception: pass
            self._draw_tick_job = None
        if self._draw_pbar is not None and self._draw_alive():
            try:
                self._draw_pbar.stop()
                self._draw_pbar.pack_forget()
            except Exception:
                pass

    def _draw_on_start(self):
        # 進度條/秒數計時在 _draw_send 已啟動;此處不覆寫(server 確認開始,不需動作)
        pass

    def _draw_on_result(self, b64: str):
        self._draw_waiting = False
        self._draw_stop_progress()
        if self._draw_alive():
            self._draw_gen_btn.config(state="normal", text="生成")
        if not b64 or not self._draw_alive():
            return
        try:
            self._draw_png = base64.b64decode(b64)        # 原始 PNG bytes 留著供下載
            self._draw_img = tk.PhotoImage(data=b64)      # Tk 8.6 直接吃 base64 PNG
            self._draw_canvas.config(image=self._draw_img, text="")
            self._draw_dl_btn.config(state="normal")
            self._draw_set_status("完成。可按「下載」存檔，或改描述再生成。")
        except Exception as e:
            self._draw_set_status(f"（圖片顯示失敗：{e}）")

    def _draw_on_error(self, msg: str):
        self._draw_waiting = False
        self._draw_stop_progress()
        if self._draw_alive():
            self._draw_gen_btn.config(state="normal", text="生成")
            self._draw_set_status(f"（{msg}）")

    def _draw_download(self):
        if not self._draw_png:
            return
        ts = datetime.now().strftime("%Y%m%d_%H%M%S")
        path = filedialog.asksaveasfilename(
            parent=self._draw_win, defaultextension=".png",
            initialfile=f"oppa_draw_{ts}.png",
            filetypes=[("PNG 圖片", "*.png")])
        if not path:
            return
        try:
            with open(path, "wb") as f:
                f.write(self._draw_png)
            self._draw_set_status(f"已下載：{path}")
        except Exception as e:
            self._draw_set_status(f"（下載失敗：{e}）")

    # ── 文獻庫管理（上傳 PDF → doctor_docs RAG，問答時引用）──────────────
    def open_docs_window(self):
        if self._docs_win is not None and tk.Toplevel.winfo_exists(self._docs_win):
            self._docs_win.deiconify(); self._docs_win.lift(); return
        win = tk.Toplevel(self.root)
        self._docs_win = win
        win.title("OPPA 文獻庫")
        win.configure(bg="#0d1117")
        win.geometry("480x520")
        win.attributes("-topmost", True)
        win.protocol("WM_DELETE_WINDOW", self._close_docs_window)
        hdr = tk.Frame(win, bg="#0d1117"); hdr.pack(fill="x", padx=8, pady=(6, 2))
        tk.Label(hdr, text="📚 醫師文獻庫（問答時自動引用）", bg="#0d1117", fg="#7ee787",
                 font=("Segoe UI", 11, "bold")).pack(side="left")
        tk.Button(hdr, text="➕ 上傳 PDF", command=self._docs_upload,
                  bg="#238636", fg="white", borderwidth=0, cursor="hand2", padx=10,
                  font=("Segoe UI", 9, "bold"), activebackground="#2ea043").pack(side="right")
        self._docs_status = tk.Label(win, text="", bg="#0d1117", fg="#8b949e",
                                     font=("Segoe UI", 9), wraplength=460, justify="left", anchor="w")
        self._docs_status.pack(fill="x", padx=10, pady=(0, 4))
        wrap = tk.Frame(win, bg="#161b22", highlightbackground="#30363d", highlightthickness=1)
        wrap.pack(fill="both", expand=True, padx=8, pady=(0, 8))
        self._docs_listframe = tk.Frame(wrap, bg="#161b22")
        self._docs_listframe.pack(fill="both", expand=True, padx=6, pady=6)
        if self.ws and self.state != "offline":
            try: self.ws.send(json.dumps({"type": "doc_list"}))
            except Exception: pass
        else:
            self._docs_set_status("未連線 OPPA。")

    def _close_docs_window(self):
        if self._docs_win is not None:
            self._docs_win.destroy()
        self._docs_win = None; self._docs_listframe = None; self._docs_status = None

    # ── 今日文獻快報視窗（讀 box 的 digest_list/digest_get；不需地端 AI 模式）──
    def open_digest_window(self):
        if self._digest_win is not None and tk.Toplevel.winfo_exists(self._digest_win):
            self._digest_win.deiconify(); self._digest_win.lift(); return
        win = tk.Toplevel(self.root); self._digest_win = win
        win.title("OPPA 今日文獻快報"); win.configure(bg="#0d1117")
        win.geometry("560x640"); win.attributes("-topmost", True)
        win.protocol("WM_DELETE_WINDOW", self._close_digest_window)
        bar = tk.Frame(win, bg="#0d1117"); bar.pack(fill="x", padx=8, pady=(6, 2))
        tk.Label(bar, text="📰 今日文獻快報", bg="#0d1117", fg="#79c0ff",
                 font=("Segoe UI", 11, "bold")).pack(side="left")
        tk.Label(bar, text="日期：", bg="#0d1117", fg="#c9d1d9",
                 font=("Segoe UI", 9)).pack(side="left", padx=(12, 0))
        self._digest_date = tk.StringVar(value="—")
        self._digest_menu = tk.OptionMenu(bar, self._digest_date, "—")
        self._digest_menu.config(bg="#21262d", fg="#c9d1d9", highlightthickness=0,
                                 font=("Segoe UI", 9), width=12)
        self._digest_menu.pack(side="left", padx=4)
        tk.Button(bar, text="複製", command=self._digest_copy, bg="#21262d", fg="#c9d1d9",
                  borderwidth=0, cursor="hand2", padx=8,
                  font=("Segoe UI", 9)).pack(side="right")
        wrap = tk.Frame(win, bg="#161b22"); wrap.pack(fill="both", expand=True, padx=8, pady=(0, 8))
        sb = tk.Scrollbar(wrap); sb.pack(side="right", fill="y")
        self._digest_text = tk.Text(wrap, wrap="word", bg="#161b22", fg="#c9d1d9",
                                    insertbackground="#c9d1d9", font=("Segoe UI", 11),
                                    yscrollcommand=sb.set, padx=8, pady=6, highlightthickness=0)
        self._digest_text.pack(side="left", fill="both", expand=True)
        sb.config(command=self._digest_text.yview)
        self._digest_text.insert("end", "每日 PubMed 頂尖期刊新文摘要（附 PMID，引用前自行核對）。"
                                 "連上 OPPA 會自動載入最新一天。\n\n")
        if self.ws and self.state != "offline":
            try: self.ws.send(json.dumps({"type": "digest_list"}))   # 先顯示現有
            except Exception: pass
            # 主動去 Liaison 拉最新（繞過 idle）→ box 套用後 feed_result 會觸發刷新清單
            try: self.liaison.force_feed_check()
            except Exception: pass
        else:
            self._digest_text.insert("end", "（未連線 OPPA）")

    def _close_digest_window(self):
        if self._digest_win is not None:
            self._digest_win.destroy()
        self._digest_win = None

    def _digest_alive(self) -> bool:
        return self._digest_win is not None and tk.Toplevel.winfo_exists(self._digest_win)

    def _digest_refresh(self):
        """feed 套用後重抓清單（新日期會出現在下拉，並自動載最新）。"""
        if self._digest_alive() and self.ws and self.state != "offline":
            try: self.ws.send(json.dumps({"type": "digest_list"}))
            except Exception: pass

    def _digest_load(self, date: str):
        if date and date != "—" and self.ws and self.state != "offline":
            try: self.ws.send(json.dumps({"type": "digest_get", "date": date}))
            except Exception: pass

    def _digest_on_list(self, dates: list):
        self._digest_dates = dates or []
        # 把 box 已有的快報日期清單交給 Liaison client → feed_check 用它算「真差集」，
        # 觸發回補歷史 archive（含比最新還舊、box 卻缺的天；補完 box 落地→清單自動刷新）。
        try:
            self.liaison.feed_have = self._digest_dates
            self.liaison.force_feed_check()
        except Exception:
            pass
        if not self._digest_alive():
            return
        menu = self._digest_menu["menu"]; menu.delete(0, "end")
        if not self._digest_dates:
            self._digest_date.set("（尚無）")
            self._digest_text.delete("1.0", "end")
            self._digest_text.insert("end", "尚無快報（此 OPPA 還沒產生每日快報）。")
            return
        for d in self._digest_dates:
            menu.add_command(label=d, command=lambda v=d: (
                self._digest_date.set(v), self._digest_load(v)))
        self._digest_date.set(self._digest_dates[0])
        self._digest_load(self._digest_dates[0])       # 自動載最新一天

    @staticmethod
    def _insert_pmid_links(w, content):
        """把快報內文插進 Text，並把 [PMID nnnn] 變成藍色底線、可點開 PubMed 的連結。"""
        w.tag_configure("pmid", foreground="#58a6ff", underline=True)
        w.tag_bind("pmid", "<Enter>", lambda e: w.config(cursor="hand2"))
        w.tag_bind("pmid", "<Leave>", lambda e: w.config(cursor=""))
        pos = 0
        for mo in PMID_RE.finditer(content):
            if mo.start() > pos:
                w.insert("end", content[pos:mo.start()])
            pmid = mo.group(1)
            tag = "pmid_" + pmid
            w.insert("end", mo.group(0), ("pmid", tag))
            w.tag_bind(tag, "<Button-1>", lambda e, p=pmid: _open_url(
                f"https://pubmed.ncbi.nlm.nih.gov/{p}/"))
            pos = mo.end()
        w.insert("end", content[pos:])

    def _digest_on_content(self, m: dict):
        if not self._digest_alive():
            return
        self._digest_text.delete("1.0", "end")
        self._insert_pmid_links(self._digest_text, m.get("content") or "（此日無快報內容）")
        self._digest_text.see("1.0")

    def _digest_copy(self):
        try:
            txt = self._digest_text.get("1.0", "end").strip()
            self.root.clipboard_clear(); self.root.clipboard_append(txt)
            if self._digest_alive():
                self._digest_win.title("OPPA 今日文獻快報（已複製）")
        except Exception:
            pass

    def _docs_alive(self) -> bool:
        return self._docs_win is not None and tk.Toplevel.winfo_exists(self._docs_win)

    def _docs_set_status(self, text: str):
        if self._docs_alive():
            self._docs_status.config(text=text)

    def _docs_render(self, docs: list):
        if not self._docs_alive():
            return
        for w in self._docs_listframe.winfo_children():
            w.destroy()
        if not docs:
            tk.Label(self._docs_listframe, text="（尚無文獻，按「上傳 PDF」加入）",
                     bg="#161b22", fg="#484f58", font=("Segoe UI", 10)).pack(anchor="w", pady=4)
            return
        for d in docs:
            row = tk.Frame(self._docs_listframe, bg="#161b22"); row.pack(fill="x", pady=2)
            tk.Button(row, text="🗑", command=lambda i=d.get("doc_id", ""): self._docs_delete(i),
                      bg="#21262d", fg="#f85149", borderwidth=0, cursor="hand2",
                      font=("Segoe UI", 9)).pack(side="right")
            tk.Label(row, text=f"• {d.get('title','')[:50]}  ({d.get('chunks',0)} 段)",
                     bg="#161b22", fg="#c9d1d9", font=("Segoe UI", 9),
                     anchor="w", justify="left", wraplength=400).pack(side="left", fill="x", expand=True)

    def _docs_delete(self, doc_id: str):
        if self.ws and self.state != "offline":
            try: self.ws.send(json.dumps({"type": "doc_delete", "doc_id": doc_id}))
            except Exception: pass

    def _docs_upload(self):
        if not self.ws or self.state == "offline":
            self._docs_set_status("未連線 OPPA，無法上傳。"); return
        paths = filedialog.askopenfilenames(
            title="選文獻 PDF（可多選）", filetypes=[("PDF", "*.pdf"), ("所有檔案", "*.*")])
        if not paths:
            return
        self._docs_set_status(f"上傳 {len(paths)} 份…")
        for p in paths:
            threading.Thread(target=self._do_doc_upload, args=(p,), daemon=True).start()

    def _do_doc_upload(self, path):
        try:
            name = os.path.basename(path)
            with open(path, "rb") as f:
                data = f.read()
            with self._ws_send_lock:
                self.ws.send(json.dumps({"type": "doc_upload_start", "name": name,
                                         "title": os.path.splitext(name)[0]}))
            CH = 256 * 1024
            for i in range(0, len(data), CH):
                with self._ws_send_lock:
                    self.ws.send(json.dumps({"type": "doc_upload_chunk",
                        "data": base64.b64encode(data[i:i + CH]).decode()}))
            with self._ws_send_lock:
                self.ws.send(json.dumps({"type": "doc_upload_end"}))
            self.root.after(0, lambda n=name: self._docs_set_status(f"已送出「{n}」，處理中…"))
        except Exception as e:
            self.root.after(0, lambda e=e: self._docs_set_status(f"上傳失敗：{e}"))

    # ── 衛教草稿視窗（grounded 在權威衛教單，須醫師審閱）──────────────────
    def open_edu_window(self):
        if self._edu_win is not None and tk.Toplevel.winfo_exists(self._edu_win):
            self._edu_win.deiconify(); self._edu_win.lift(); return
        win = tk.Toplevel(self.root); self._edu_win = win
        win.title("OPPA 衛教草稿"); win.configure(bg="#0d1117")
        win.geometry("520x600"); win.minsize(420, 460); win.attributes("-topmost", True)
        win.protocol("WM_DELETE_WINDOW", self._close_edu_window)
        tk.Label(win, text="✍️ 衛教草稿（依權威衛教單起草，須醫師審閱後再給病人）",
                 bg="#0d1117", fg="#ffab70", font=("Segoe UI", 11, "bold")).pack(anchor="w", padx=8, pady=(6, 2))
        f1 = tk.Frame(win, bg="#0d1117"); f1.pack(fill="x", padx=8, pady=2)
        tk.Label(f1, text="主題：", bg="#0d1117", fg="#8b949e", font=("Segoe UI", 9)).pack(side="left")
        self._edu_topic = tk.Entry(f1, bg="#161b22", fg="#c9d1d9", relief="flat",
                                   insertbackground="#c9d1d9", font=("Segoe UI", 10))
        self._edu_topic.pack(side="left", fill="x", expand=True, ipady=3)
        self._edu_topic.bind("<Return>", lambda e: self._edu_send())
        f2 = tk.Frame(win, bg="#0d1117"); f2.pack(fill="x", padx=8, pady=2)
        tk.Label(f2, text="醫囑：", bg="#0d1117", fg="#8b949e", font=("Segoe UI", 9)).pack(side="left")
        self._edu_plan = tk.Entry(f2, bg="#161b22", fg="#c9d1d9", relief="flat",
                                  insertbackground="#c9d1d9", font=("Segoe UI", 10))
        self._edu_plan.pack(side="left", fill="x", expand=True, ipady=3)
        self._edu_btn2 = tk.Button(f2, text="生成", command=self._edu_send, bg="#bb8009",
                                   fg="white", borderwidth=0, cursor="hand2", padx=12,
                                   font=("Segoe UI", 10, "bold"), activebackground="#d29922")
        self._edu_btn2.pack(side="right", padx=(6, 0))
        self._edu_status = tk.Label(win, text="輸入主題（須為衛教庫涵蓋的病症）+ 本次醫囑，按生成。",
                                    bg="#0d1117", fg="#8b949e", font=("Segoe UI", 9),
                                    wraplength=500, justify="left", anchor="w")
        self._edu_status.pack(fill="x", padx=10, pady=(2, 4))
        wrap = tk.Frame(win, bg="#0d1117"); wrap.pack(fill="both", expand=True, padx=8)
        sb = tk.Scrollbar(wrap); sb.pack(side="right", fill="y")
        self._edu_text = tk.Text(wrap, wrap="word", bg="#161b22", fg="#c9d1d9", relief="flat",
                                 padx=8, pady=6, font=("Segoe UI", 10), highlightthickness=0,
                                 yscrollcommand=sb.set)
        self._edu_text.pack(side="left", fill="both", expand=True); sb.config(command=self._edu_text.yview)
        self._edu_text.config(state="disabled")
        bottom = tk.Frame(win, bg="#0d1117"); bottom.pack(fill="x", padx=8, pady=6)
        tk.Button(bottom, text="📋 複製", command=self._edu_copy, bg="#21262d", fg="#a5d6ff",
                  borderwidth=0, cursor="hand2", padx=10, font=("Segoe UI", 9, "bold"),
                  activebackground="#30363d").pack(side="left")
        tk.Button(bottom, text="⬇ 下載 .txt", command=self._edu_download, bg="#21262d", fg="#a5d6ff",
                  borderwidth=0, cursor="hand2", padx=10, font=("Segoe UI", 9, "bold"),
                  activebackground="#30363d").pack(side="left", padx=(6, 0))
        self._edu_topic.focus_set()

    def _close_edu_window(self):
        if self._edu_win is not None:
            self._edu_win.destroy()
        self._edu_win = None; self._edu_text = None; self._edu_status = None

    def _edu_alive(self) -> bool:
        return self._edu_win is not None and tk.Toplevel.winfo_exists(self._edu_win)

    def _edu_set_status(self, text: str):
        if self._edu_alive():
            self._edu_status.config(text=text)

    def _edu_append(self, text: str):
        if not self._edu_alive():
            return
        self._edu_text.config(state="normal"); self._edu_text.insert("end", text)
        self._edu_text.see("end"); self._edu_text.config(state="disabled")

    def _edu_send(self):
        if self._edu_waiting or not self._edu_alive():
            return
        topic = self._edu_topic.get().strip()
        if not topic:
            return
        if not self.ws or self.state == "offline":
            self._edu_set_status("未連線 OPPA，無法起草。"); return
        self._edu_waiting = True; self._edu_content = ""
        self._edu_btn2.config(state="disabled", text="…")
        self._edu_text.config(state="normal"); self._edu_text.delete("1.0", "end")
        self._edu_text.config(state="disabled")
        self._edu_set_status("🖊 起草中…（依衛教單，約 20–40 秒）")
        try:
            self.ws.send(json.dumps({"type": "edu_draft", "topic": topic,
                                     "plan": self._edu_plan.get().strip()}))
        except Exception as e:
            self._edu_waiting = False
            self._edu_btn2.config(state="normal", text="生成")
            self._edu_set_status(f"（送出失敗：{e}）")

    def _edu_on_start(self):
        self._edu_set_status("🖊 起草中…")

    def _edu_on_chunk(self, delta: str):
        self._edu_content += delta
        self._edu_append(delta)

    def _edu_on_end(self, seconds):
        self._edu_waiting = False
        if self._edu_alive():
            self._edu_btn2.config(state="normal", text="生成")
            self._edu_set_status(f"完成（{seconds}s）。請審閱後再給病人；可複製/下載。")

    def _edu_on_sources(self, items: list):
        if not items or not self._edu_alive():
            return
        self._edu_append("\n———\n來源：" + "、".join(
            f"{s.get('tag','')}〔{s.get('title','')}〕" for s in items) + "\n")

    def _edu_on_error(self, msg: str):
        self._edu_waiting = False
        if self._edu_alive():
            self._edu_btn2.config(state="normal", text="生成")
            self._edu_set_status(f"（{msg}）")

    def _edu_copy(self):
        if self._edu_alive() and self._edu_content:
            self.root.clipboard_clear(); self.root.clipboard_append(self._edu_content)
            self._edu_set_status("已複製到剪貼簿。")

    def _edu_download(self):
        if not self._edu_content:
            return
        ts = datetime.now().strftime("%Y%m%d_%H%M%S")
        path = filedialog.asksaveasfilename(parent=self._edu_win, defaultextension=".txt",
                                            initialfile=f"衛教草稿_{ts}.txt",
                                            filetypes=[("文字檔", "*.txt")])
        if not path:
            return
        try:
            with open(path, "w", encoding="utf-8") as f:
                f.write(self._edu_content)
            self._edu_set_status(f"已下載：{path}")
        except Exception as e:
            self._edu_set_status(f"（下載失敗：{e}）")

    # ── 問答視窗（獨立 Toplevel，本地端迷你助理）─────────────────────────
    def open_qa_window(self):
        """開（或聚焦）問答視窗。單例：已開就拉到最前。"""
        if self._qa_win is not None and tk.Toplevel.winfo_exists(self._qa_win):
            self._qa_win.deiconify()
            self._qa_win.lift()
            self._qa_input.focus_set()
            return
        win = tk.Toplevel(self.root)
        self._qa_win = win
        win.title("OPPA 問答")
        win.configure(bg="#0d1117")
        win.geometry("460x560")
        win.minsize(360, 360)
        win.attributes("-topmost", True)
        win.protocol("WM_DELETE_WINDOW", self._close_qa_window)

        hdr = tk.Frame(win, bg="#0d1117")
        hdr.pack(fill="x", padx=8, pady=(6, 2))
        tk.Label(hdr, text="OPPA 離線問答", bg="#0d1117", fg="#a5d6ff",
                 font=("Segoe UI", 11, "bold")).pack(side="left")
        tk.Button(hdr, text="清空", command=self._qa_clear,
                  bg="#21262d", fg="#8b949e", borderwidth=0, cursor="hand2",
                  padx=8, font=("Segoe UI", 9),
                  activebackground="#30363d").pack(side="right")

        log_wrap = tk.Frame(win, bg="#0d1117")
        log_wrap.pack(fill="both", expand=True, padx=8)
        sb = tk.Scrollbar(log_wrap)
        sb.pack(side="right", fill="y")
        self._qa_log = tk.Text(log_wrap, wrap="word", bg="#161b22",
                               fg="#c9d1d9", relief="flat", padx=8, pady=6,
                               font=("Segoe UI", 10), highlightthickness=0,
                               yscrollcommand=sb.set)
        self._qa_log.pack(side="left", fill="both", expand=True)
        sb.config(command=self._qa_log.yview)
        self._qa_log.tag_config("user", foreground="#79c0ff",
                                font=("Segoe UI", 10, "bold"))
        self._qa_log.tag_config("assistant", foreground="#c9d1d9")
        self._qa_log.tag_config("sys", foreground="#8b949e",
                                font=("Segoe UI", 9, "italic"))
        self._qa_log.tag_config("srchead", foreground="#3fb950",
                                font=("Segoe UI", 9, "bold"))
        self._qa_log.tag_config("src", foreground="#8b949e",
                                font=("Segoe UI", 9))
        self._qa_log.config(state="disabled")

        in_frame = tk.Frame(win, bg="#0d1117")
        in_frame.pack(fill="x", padx=8, pady=8)
        self._qa_input = tk.Text(in_frame, height=3, wrap="word", bg="#0d1117",
                                 fg="#c9d1d9", relief="flat", padx=6, pady=4,
                                 insertbackground="#c9d1d9",
                                 font=("Segoe UI", 10),
                                 highlightbackground="#30363d",
                                 highlightthickness=1)
        self._qa_input.pack(side="left", fill="both", expand=True)
        # Enter 送出，Shift+Enter 換行
        self._qa_input.bind("<Return>", self._qa_on_return)
        self._qa_input.bind("<Shift-Return>", lambda e: None)
        self._qa_send_btn = tk.Button(in_frame, text="送出",
                                      command=self._qa_send,
                                      bg="#238636", fg="white", borderwidth=0,
                                      cursor="hand2", padx=12,
                                      font=("Segoe UI", 10, "bold"),
                                      activebackground="#2ea043")
        self._qa_send_btn.pack(side="right", fill="y", padx=(6, 0))

        if not self._qa_history:
            self._qa_append("sys", "本地端離線助理 — 看診中會暫停，結束後再問。\n")
        else:
            self._qa_replay_history()
        self._qa_input.focus_set()

    def _close_qa_window(self):
        if self._qa_win is not None:
            self._qa_win.destroy()
        self._qa_win = None
        self._qa_log = None
        self._qa_input = None

    def _qa_clear(self):
        self._qa_history = []
        if self._qa_log is not None:
            self._qa_log.config(state="normal")
            self._qa_log.delete("1.0", "end")
            self._qa_log.config(state="disabled")
            self._qa_append("sys", "已清空對話。\n")

    def _qa_alive(self) -> bool:
        return self._qa_log is not None and tk.Toplevel.winfo_exists(self._qa_win)

    def _qa_append(self, tag: str, text: str):
        if not self._qa_alive():
            return
        self._qa_log.config(state="normal")
        self._qa_log.insert("end", text, tag)
        self._qa_log.see("end")
        self._qa_log.config(state="disabled")

    def _qa_replay_history(self):
        for h in self._qa_history:
            who = "你：" if h.get("role") == "user" else "OPPA："
            self._qa_append(h.get("role", "assistant"),
                            f"{who}{h.get('content','')}\n\n")

    def _qa_on_return(self, event):
        self._qa_send()
        return "break"   # 不要在輸入框留下換行

    def _qa_send(self):
        if self._qa_waiting or not self._qa_alive():
            return
        q = self._qa_input.get("1.0", "end").strip()
        if not q:
            return
        if not self.ws or self.state == "offline":
            self._qa_append("sys", "（目前未連線 OPPA，無法問答）\n")
            return
        self._qa_input.delete("1.0", "end")
        self._qa_append("user", f"你：{q}\n\n")
        self._qa_history.append({"role": "user", "content": q})
        self._qa_waiting = True
        self._qa_send_btn.config(state="disabled", text="…")
        try:
            self.ws.send(json.dumps({"type": "qa_ask", "text": q,
                                     "history": self._qa_history[:-1]}))
        except Exception as e:
            self._qa_waiting = False
            self._qa_send_btn.config(state="normal", text="送出")
            self._qa_append("sys", f"（送出失敗：{e}）\n")

    # 以下 _qa_assistant_* 由 on_message 經 root.after 呼叫（主線程）
    def _qa_assistant_start(self):
        self._qa_cur = ""
        self._qa_append("assistant", "OPPA：")

    def _qa_assistant_chunk(self, delta: str):
        self._qa_cur = getattr(self, "_qa_cur", "") + delta
        self._qa_append("assistant", delta)

    def _qa_assistant_end(self, seconds):
        self._qa_append("assistant", "\n\n")
        content = getattr(self, "_qa_cur", "").strip()
        if content:
            self._qa_history.append({"role": "assistant", "content": content})
        self._qa_cur = ""
        self._qa_waiting = False
        if self._qa_alive():
            self._qa_send_btn.config(state="normal", text="送出")

    def _qa_busy(self):
        # server 因看診中讓路 → 把剛 append 的問題留著、回復可再送
        self._qa_waiting = False
        if self._qa_alive():
            self._qa_send_btn.config(state="normal", text="送出")
        self._qa_append("sys", "（看診中，問答暫停；看診結束後再送一次）\n\n")
        # 該則問題沒被回答 → 從 history 移除，避免污染下次上下文
        if self._qa_history and self._qa_history[-1].get("role") == "user":
            self._qa_history.pop()

    def _qa_error(self, msg: str):
        self._qa_waiting = False
        if self._qa_alive():
            self._qa_send_btn.config(state="normal", text="送出")
        self._qa_append("sys", f"（{msg}）\n\n")

    def _qa_sources(self, items: list):
        """回答後附「來源」清單（健保=原文節錄/指引/文獻）。"""
        if not items or not self._qa_alive():
            return
        self._qa_append("srchead", f"📑 來源（{len(items)}）\n")
        for i, s in enumerate(items, 1):
            tag = s.get("tag", "")
            title = (s.get("title") or "").strip()
            ex = (s.get("excerpt") or "").strip()
            self._qa_append("src", f"  [{i}｜{tag}] {title}\n")
            if ex:
                self._qa_append("src", f"      {ex}\n")
        self._qa_append("src", "\n")

    # ── Actions ──────────────────────────────────────────────────
    def refresh_soap(self):
        if self.ws and self.state != "offline":
            try:
                self.ws.send(json.dumps({"type": "get_latest_soap"}))
            except Exception:
                pass

    def refresh_recent_soaps(self):
        """Pull 最近 20 筆 SOAP，server 端會過 status=ok。Widget 端再 filter 今天。"""
        if self.ws and self.state != "offline":
            try:
                self.ws.send(json.dumps({"type": "get_recent_soaps", "limit": 20}))
            except Exception:
                pass

    # ── 匯出當日/當月完整紀錄 ───────────────────────────────────────────
    def export_records(self, scope: str):
        """向 server 要當日/當月完整紀錄；回來後寫 CSV（見 _write_export）。"""
        if not self.ws or self.state == "offline":
            messagebox.showwarning("OPPA 匯出", "目前未連線，無法匯出。")
            return
        try:
            self.ws.send(json.dumps({"type": "export_records", "scope": scope}))
        except Exception as e:
            messagebox.showerror("OPPA 匯出", f"匯出請求失敗：{e}")

    _KMS_FAIL_MSG = {
        "expired_no_export": "授權已到期/未續約，無法匯出歷史明文紀錄。\n"
                             "（即時看診不受影響；請續約後再匯出）",
        "suspended": "此診所已停權，無法匯出歷史紀錄。請聯絡 OPPA。",
        "not_licensed": "授權未生效，無法匯出。請續約後再試。",
        "offline": "目前未連線 Liaison，無法解密匯出。\n請連上網路後再試（即時看診不受影響）。",
        "timeout": "解密授權逾時，請稍後重試。",
        "no_crypto": "此版本 widget 缺解密元件，請更新 widget。",
        "no_liaison": "未設定 Liaison 連線，無法解密匯出。",
    }

    def _write_export(self, scope: str, rows: list):
        label = "當月" if scope == "month" else "當日"
        if not rows:
            messagebox.showinfo("OPPA 匯出", f"{label}查無紀錄可匯出。")
            return
        default = f"OPPA_{label}紀錄_{datetime.now().strftime('%Y%m%d')}.csv"
        path = filedialog.asksaveasfilename(
            title=f"匯出{label}完整紀錄", defaultextension=".csv",
            initialfile=default, filetypes=[("CSV (Excel)", "*.csv"), ("All", "*.*")])
        if not path:
            return
        cols = [("date", "日期"), ("time", "時間"), ("visit_no", "診號"),
                ("specialty", "科別"), ("git_sha", "Git版本"),
                ("recording_seconds", "對話長度(秒)"), ("asr_seconds", "ASR(秒)"),
                ("soap_seconds", "SOAP(秒)"), ("primary_dx", "主診斷"),
                ("secondary_dxs", "次診斷"), ("S", "S"), ("O", "O"),
                ("A", "A"), ("P", "P"), ("references", "References"),
                ("transcript", "逐字稿")]
        encrypted = any(r.get("enc") for r in rows)

        def _do():
            # 落地加密：歷史紀錄落地為密文 → 先經 Liaison KMS 授權解回明文（擋匯出）。
            if encrypted:
                ok, reason = _decrypt_export_rows(rows, getattr(self, "liaison", None))
                if not ok:
                    msg = self._KMS_FAIL_MSG.get(reason, f"解密失敗（{reason}）")
                    self.root.after(0, lambda: messagebox.showwarning("OPPA 匯出", msg))
                    return
            try:
                with open(path, "w", newline="", encoding="utf-8-sig") as f:  # utf-8-sig: Excel 中文
                    w = csv.writer(f)
                    w.writerow([c[1] for c in cols])
                    for r in rows:
                        w.writerow([str(r.get(c[0], "") or "") for c in cols])
                self.root.after(0, lambda: messagebox.showinfo(
                    "OPPA 匯出", f"已匯出 {len(rows)} 筆{label}紀錄：\n{path}"))
            except Exception as e:
                self.root.after(0, lambda: messagebox.showerror("OPPA 匯出", f"寫檔失敗：{e}"))

        # kms_unwrap 可能阻塞數十秒 → 丟背景執行緒，避免凍結 UI
        threading.Thread(target=_do, daemon=True).start()

    # ── 匯出防核刪稽核報告（指定日期 / 整月）──────────────────────────────
    def _pick_recon_date(self):
        """跳日曆讓使用者選某天 → 下載那天的防核刪稽核報告。"""
        if not self.ws or self.state == "offline":
            messagebox.showwarning("OPPA 防核刪", "目前未連線，無法匯出。")
            return
        import calendar as _cal
        import datetime as _dt
        today = _dt.date.today()
        st = {"y": today.year, "m": today.month}

        top = tk.Toplevel(self.root)
        top.title("選擇防核刪稽核日期")
        top.configure(bg="#0d1117")
        top.attributes("-topmost", True)
        top.resizable(False, False)
        # 開日曆期間別讓主視窗因失焦被收回
        self._modal_open = True

        def _close():
            self._modal_open = False
            try:
                top.destroy()
            except Exception:
                pass
        top.protocol("WM_DELETE_WINDOW", _close)

        hdr = tk.Frame(top, bg="#0d1117")
        hdr.pack(fill="x", padx=8, pady=(8, 4))
        title = tk.Label(hdr, bg="#0d1117", fg="#c9d1d9",
                         font=("Segoe UI", 11, "bold"))
        grid = tk.Frame(top, bg="#0d1117")
        grid.pack(padx=8, pady=(0, 10))

        def pick(d):
            self._modal_open = False
            try:
                top.destroy()
            except Exception:
                pass
            self.export_recon("date", d.strftime("%Y-%m-%d"))

        def nav(delta):
            m, y = st["m"] + delta, st["y"]
            if m < 1:
                m, y = 12, y - 1
            elif m > 12:
                m, y = 1, y + 1
            st["m"], st["y"] = m, y
            render()

        def render():
            title.config(text=f"{st['y']} 年 {st['m']:02d} 月")
            for w in grid.winfo_children():
                w.destroy()
            for c, wd in enumerate(["一", "二", "三", "四", "五", "六", "日"]):
                tk.Label(grid, text=wd, bg="#0d1117", fg="#8b949e",
                         font=("Segoe UI", 9), width=4).grid(row=0, column=c)
            cal = _cal.Calendar(firstweekday=0)  # 週一為首
            for r, week in enumerate(cal.monthdatescalendar(st["y"], st["m"]), 1):
                for c, day in enumerate(week):
                    if day.month != st["m"]:
                        tk.Label(grid, text="", bg="#0d1117",
                                 width=4).grid(row=r, column=c)
                        continue
                    fut = day > today           # 未來沒報告 → 不可選
                    is_today = day == today
                    tk.Button(grid, text=str(day.day), width=4,
                              bg=("#1f6feb" if is_today else "#161b22") if not fut else "#0d1117",
                              fg="#c9d1d9" if not fut else "#484f58",
                              borderwidth=0,
                              cursor="hand2" if not fut else "arrow",
                              activebackground="#388bfd",
                              state="normal" if not fut else "disabled",
                              command=(lambda d=day: pick(d))).grid(
                                  row=r, column=c, padx=1, pady=1)

        tk.Button(hdr, text="◀", command=lambda: nav(-1),
                  bg="#21262d", fg="#c9d1d9", borderwidth=0,
                  cursor="hand2", width=3).pack(side="left")
        title.pack(side="left", expand=True)
        tk.Button(hdr, text="▶", command=lambda: nav(1),
                  bg="#21262d", fg="#c9d1d9", borderwidth=0,
                  cursor="hand2", width=3).pack(side="right")
        render()
        top.update_idletasks()
        top.geometry(f"+{self.root.winfo_rootx() + 20}+{self.root.winfo_rooty() + 40}")

    def export_recon(self, scope: str, date_str: str = None):
        """向 server 要防核刪稽核報告(scope=date 指定日 / month 整月)；回 .md。"""
        if not self.ws or self.state == "offline":
            messagebox.showwarning("OPPA 防核刪", "目前未連線，無法匯出。")
            return
        try:
            req = {"type": "export_recon", "scope": scope}
            if date_str:
                req["date"] = date_str
            self.ws.send(json.dumps(req))
        except Exception as e:
            messagebox.showerror("OPPA 防核刪", f"匯出請求失敗：{e}")

    def _write_recon(self, scope: str, content: str, date_str: str = None):
        label = "整月" if scope == "month" else (date_str or "當日")
        if not content.strip():
            messagebox.showinfo("OPPA 防核刪", f"{label} 查無防核刪稽核報告。")
            return
        tag = date_str if (scope == "date" and date_str) else \
            ("當月_" + datetime.now().strftime('%Y%m') if scope == "month"
             else datetime.now().strftime('%Y%m%d'))
        default = f"OPPA_防核刪稽核_{tag}.md"
        path = filedialog.asksaveasfilename(
            title=f"匯出防核刪稽核報告（{label}）", defaultextension=".md",
            initialfile=default, filetypes=[("Markdown", "*.md"), ("文字檔", "*.txt"), ("All", "*.*")])
        if not path:
            return
        try:
            with open(path, "w", encoding="utf-8") as f:
                f.write(content)
            messagebox.showinfo("OPPA 防核刪", f"已匯出防核刪稽核報告（{label}）：\n{path}")
        except Exception as e:
            messagebox.showerror("OPPA 防核刪", f"寫檔失敗：{e}")

    def _update_dropdown(self):
        """從 self._recent_soaps 重畫下拉值。維持當前選取（如果還在）。"""
        if not self._recent_soaps:
            self.dropdown["values"] = ["(本日無 SOAP)"]
            self.dropdown_var.set("(本日無 SOAP)")
            return
        labels = []
        for r in self._recent_soaps:
            ts = (r.get("record_stopped_at") or "")[11:16]  # HH:MM
            dx = r.get("primary_dx") or "(無 dx)"
            vn = r.get("visit_no") or ""
            head = f"{ts} 診{vn}" if vn else ts
            labels.append(f"{head} — {dx[:42]}")
        self.dropdown["values"] = labels
        # 維持當前選取
        if self._selected_wav:
            for i, r in enumerate(self._recent_soaps):
                if r.get("wav") == self._selected_wav:
                    self.dropdown.current(i)
                    self._update_version_buttons()  # refresh 後 has_audit 可能變 true → 解灰 V12
                    return
        # 沒手動選 → 預設顯示最新一筆（不切換 selection state，user 仍可手動再選）
        self.dropdown.current(0)
        self._update_version_buttons()

    def _on_dropdown_select(self, event=None):
        """User 從下拉選了某筆 → 用當前 version 切換 sections + dx 顯示。"""
        idx = self.dropdown.current()
        if idx < 0 or idx >= len(self._recent_soaps):
            return
        row = self._recent_soaps[idx]
        self._selected_wav = row.get("wav")
        self._render_selected()

    def _on_version_toggle(self, version: str):
        """切 V13 ↔ V12 audit 顯示版本。若 selected session 沒 audit，V12
        按鈕不會 enable（_update_version_toggle 處理 grayout）。"""
        if version not in ("v13", "v12"):
            return
        # selected session 沒 audit 不准切 v12
        if version == "v12":
            row = self._current_row()
            if not row or not row.get("has_audit"):
                return
        self._version = version
        self._update_version_buttons()
        self._render_selected()

    def _current_row(self) -> dict | None:
        """回當前 selected session 的 row，沒選就回最新一筆。"""
        if not self._recent_soaps:
            return None
        if self._selected_wav:
            for r in self._recent_soaps:
                if r.get("wav") == self._selected_wav:
                    return r
        return self._recent_soaps[0]

    def _update_version_buttons(self):
        """畫 V13/V12 按鈕當前選取/灰掉狀態。"""
        active = ("#1f6feb", "white")
        idle = ("#21262d", "#8b949e")
        disabled = ("#161b22", "#484f58")
        v13_state = active if self._version == "v13" else idle
        self.v13_btn.config(bg=v13_state[0], fg=v13_state[1],
                              font=("Segoe UI", 9, "bold" if self._version == "v13" else "normal"))
        row = self._current_row()
        if row and row.get("has_audit"):
            v12_state = active if self._version == "v12" else idle
            self.v12_btn.config(bg=v12_state[0], fg=v12_state[1], state="normal",
                                  font=("Segoe UI", 9, "bold" if self._version == "v12" else "normal"))
            # dx 一致/不一致
            if row.get("dx_match"):
                self.dx_match_label.config(text="✓ V13/V12 dx 一致", fg="#56d364")
            else:
                v12dx = row.get("v12_primary_dx", "")
                self.dx_match_label.config(text=f"✗ V12: {v12dx[:30]}", fg="#f0883e")
        else:
            self.v12_btn.config(bg=disabled[0], fg=disabled[1], state="disabled",
                                  font=("Segoe UI", 9))
            self.dx_match_label.config(text="(無 V12 audit)", fg="#484f58")

    def _render_selected(self):
        """依 self._version + self._selected_wav 渲染 sections + dx。"""
        row = self._current_row()
        if not row:
            return
        if self._version == "v12" and row.get("has_audit"):
            self.last_sections = {
                "S": row.get("v12_S", ""),
                "O": row.get("v12_O", ""),
                "A": row.get("v12_A", ""),
                "P": row.get("v12_P", ""),
            }
            self.last_soap_text = _strip_prov_tags(row.get("v12_soap_raw", ""))
            self.last_dx = row.get("v12_primary_dx", "") or ""
        else:
            self.last_sections = {
                "S": row.get("S", ""),
                "O": row.get("O", ""),
                "A": row.get("A", ""),
                "P": row.get("P", ""),
            }
            self.last_soap_text = _strip_prov_tags(row.get("soap_raw", ""))
            self.last_dx = row.get("primary_dx", "") or ""
        self._update_sections_ui()
        self._update_version_buttons()

    def copy_section(self, sec: str):
        text = self.last_sections.get(sec, "")
        if not text:
            return
        pyperclip.copy(_crlf(text))
        # 視覺反饋：閃一下 cell border
        try:
            w = self.section_widgets[sec]
            cell = w.master
            cell.config(highlightbackground="#3fb950")
            self.root.after(400,
                lambda: cell.config(highlightbackground="#30363d"))
        except Exception:
            pass

    def copy_all(self):
        # 整段複製：把 SOAP 全文複製到剪貼簿（醫師自行在 HIS Ctrl+V,不再自動貼、不用 F9）。
        # 若 user 從下拉選了特定一筆 → 複製那筆,否則 refresh latest 再複製。
        if self._selected_wav and self.last_soap_text:
            soap = self.last_soap_text
        else:
            if self.ws and self.state != "offline":
                self._soap_event.clear()
                self._soap_pending = ""
                try:
                    self.ws.send(json.dumps({"type": "get_latest_soap"}))
                    self._soap_event.wait(timeout=2)
                except Exception:
                    pass
            soap = self._soap_pending or self.last_soap_text
        if not soap:
            return
        soap = soap.split("\n\nReferences:")[0]
        pyperclip.copy(_crlf(_strip_prov_tags(soap)))
        # 只複製到剪貼簿,醫師自行在 HIS 按 Ctrl+V 貼上(不再自動送 Ctrl+V、不用 F9)。

    def start(self):
        # F9 熱鍵已移除：整段複製改成按鈕、只複製不自動貼上。
        # WS background
        threading.Thread(target=self.run_ws, daemon=True).start()
        # HIS 整合本機 API（127.0.0.1）：HIS 點病人 → POST /visit 推診號 → 轉給 OPPA →
        # kiosk 顯示診號。預設開（OPPA_HIS_API_PORT，0=關）。
        self._start_his_api()
        # 第一方內部本地 LLM 通道（always-on，localhost）：OPPA 自家 Windows 程式打這個用 26B
        self._start_local_llm_api()
        # 維修隧道（widget 開著就維持；maint_enabled=0 或未設 liaison_host 時 no-op）
        self.maint.start()
        # Liaison 控制通道：**等拿到 OPPA 指紋後才登入**（token 登入要帶指紋，
        # 太早送會 fingerprint 空→被拒 missing）。由 machine_fp handler 觸發 start()。
        # 例外：連不到 OPPA（取不到 fp）時的保險 → 延遲後若仍沒登入過就用現有憑證起。
        self.root.after(8000, self._liaison_start_fallback)
        # 每小時把本機時間推給 OPPA（離線時鐘校正；首次校正在 on_open 連上時就做）
        self.root.after(3600 * 1000, self.push_time)
        # 每 20 分鐘 OTA 重查（解「一直在線沒重連→拿不到新版」；真更新仍受 OPPA 30min idle 閘）
        self.root.after(20 * 60 * 1000, self._ota_recheck_tick)
        # Tk mainloop (blocking)
        self.root.mainloop()


def main():
    args = parse_args()
    if not CONFIG_PATH.exists():
        write_config_template()
    cfg = read_config()
    server_ip = (args.server or cfg.get("server_ip") or "").strip() or None
    port = args.port or int(cfg.get("port", 8766))
    hotkey = args.hotkey or cfg.get("hotkey", "f9")
    subnet = args.subnet or cfg.get("subnet", "192.168.99")
    scan_timeout = args.scan_timeout or float(cfg.get("scan_timeout", 0.5))

    # 維修隧道設定（缺 key 用預設；maint_enabled=0 或 liaison_host 空 → 不啟動）
    maint_cfg = {
        "maint_enabled": cfg.get("maint_enabled", "0"),
        "liaison_host":  cfg.get("liaison_host", ""),
        "liaison_port":  cfg.get("liaison_port", "22"),
        "tunnel_user":   cfg.get("tunnel_user", "tunnel"),
        "ssh_key":       cfg.get("ssh_key", ""),
        "relay_bind":    cfg.get("relay_bind", "127.0.0.1"),
        "relay_port":    cfg.get("relay_port", "2299"),
        "oppa_ssh_host": cfg.get("oppa_ssh_host", "192.168.99.99"),
        "oppa_ssh_port": cfg.get("oppa_ssh_port", "22"),
    }

    # Liaison 登入/續費設定（空 url/clinic_id → LiaisonClient no-op）
    liaison_cfg = {
        "liaison_ws_url":     cfg.get("liaison_ws_url", ""),
        "clinic_id":          cfg.get("clinic_id", ""),
        "clinic_secret":      cfg.get("clinic_secret", ""),
        "license_grace_days": cfg.get("license_grace_days", "14"),
        "maint_stream_enabled": cfg.get("maint_stream_enabled", "1"),
        "oppa_ssh_host":      cfg.get("oppa_ssh_host", "192.168.99.99"),
        "oppa_ssh_port":      cfg.get("oppa_ssh_port", "22"),
        "version":            "v14",
    }

    print(f"OPPA Widget starting — "
          f"{'fixed ws://' + server_ip + ':' + str(port) if server_ip else f'scan {subnet}.1-99:{port}'}  "
          f"hotkey: {hotkey}  "
          f"maint:{'on' if str(maint_cfg['maint_enabled']).lower() in ('1','true','yes','on') and maint_cfg['liaison_host'] else 'off'}",
          file=sys.stderr)
    Widget(server_ip, port, hotkey, subnet, scan_timeout,
           maint_cfg=maint_cfg, liaison_cfg=liaison_cfg).start()


if __name__ == "__main__":
    main()
