#!/usr/bin/env python3
"""OPPA 家用研究 widget — 醫師帶 OPPA 機回家、RJ-45 接自己電腦寫 paper。

獨立於診所 widget：無登入 / 授權 / HIS / 診號 / 維修隧道。只連本機 OPPA box 的
WebSocket（預設 ws://192.168.99.99:8766，RJ-45 直連的 box IP），用三個面板：

  ✍️ 寫作     — Gemma-4 學術寫作助理（長輸出，草擬 / 改寫 / 潤稿；可勾附文獻）
  🔎 文獻查詢 — 打機上 22M PubMed abstracts + 指引，答案附 PMID
  🖼️ 文件 OCR — 選本機圖/掃描頁 → 機上視覺 OCR → 文字（丟進寫作或查詢）

server 端點：research_ask{text,history,mode,use_lit} → research_start/_chunk/_end/
_sources/_busy/_error；doc_ocr_start/_chunk/_end → doc_ocr_result{ok,text}。

跑：python3 research_widget.py  或  OPPA_RESEARCH_WS=ws://192.168.99.99:8766 python3 …
依賴：websocket-client（pip install websocket-client）。
"""
import base64
import json
import os
import queue
import re
import threading
import time
import tkinter as tk
from tkinter import filedialog, messagebox, ttk

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


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

import websocket

DEFAULT_WS = os.environ.get("OPPA_RESEARCH_WS", "ws://192.168.99.99:8766")
BG = "#0d1117"; FG = "#e6edf3"; SUB = "#8b949e"; ACC = "#ffd6a5"
PANEL = "#161b22"; BTN = "#238636"; BTN2 = "#1f6feb"; MUT = "#21262d"


class ResearchWidget:
    def __init__(self):
        self.root = tk.Tk()
        self.root.title("OPPA 研究站（家用）")
        self.root.configure(bg=BG)
        self.root.geometry("880x720")
        self.ws = None
        self.connected = False
        self._edge_on = False              # 地端 AI 模式（研究功能只在此模式開啟）
        self._q = queue.Queue()            # ws thread → tk 主緒
        # 各面板對話 history（無狀態 server，widget 帶上下文）
        self._hist = {"write": [], "query": []}
        self._active = None                # 目前串流中的面板 key
        self._ocr_busy = False
        self._build_ui()
        self._pump()                       # 啟動 tk 端訊息幫浦
        threading.Thread(target=self._ws_loop, daemon=True).start()

    # ── UI ────────────────────────────────────────────────────────────────
    def _build_ui(self):
        top = tk.Frame(self.root, bg=BG); top.pack(fill="x", padx=10, pady=(8, 4))
        tk.Label(top, text="🔬 OPPA 研究站", bg=BG, fg=ACC,
                 font=("Segoe UI", 14, "bold")).pack(side="left")
        self._dot = tk.Label(top, text="●", bg=BG, fg="#f85149",
                             font=("Segoe UI", 12)); self._dot.pack(side="left", padx=(10, 2))
        self._conn = tk.Label(top, text="連線中…", bg=BG, fg=SUB,
                              font=("Segoe UI", 9)); self._conn.pack(side="left")
        tk.Label(top, text="Box:", bg=BG, fg=SUB, font=("Segoe UI", 9)).pack(side="left", padx=(14, 2))
        self._host = tk.Entry(top, width=26, bg=PANEL, fg=FG, insertbackground=FG,
                              font=("Consolas", 9)); self._host.insert(0, DEFAULT_WS)
        self._host.pack(side="left")
        tk.Button(top, text="重連", command=self._reconnect, bg=MUT, fg=FG,
                  borderwidth=0, cursor="hand2", padx=8, font=("Segoe UI", 8)).pack(side="left", padx=4)

        # 研究模式（地端 AI）橫幅：研究功能只在進入後開啟，避免看診中誤用打架
        banner = tk.Frame(self.root, bg=PANEL); banner.pack(fill="x", padx=10, pady=(0, 2))
        self._mode_lbl = tk.Label(banner, text="研究模式：關（尚未進入地端 AI）", bg=PANEL,
                                  fg="#f0883e", font=("Segoe UI", 10, "bold"))
        self._mode_lbl.pack(side="left", padx=8, pady=4)
        self._mode_btn = tk.Button(banner, text="▶ 進入研究模式（地端 AI）",
                                   command=self._toggle_mode, bg=BTN, fg="#fff",
                                   borderwidth=0, cursor="hand2", padx=12, pady=4,
                                   font=("Segoe UI", 10, "bold"), activebackground="#2ea043")
        self._mode_btn.pack(side="right", padx=8, pady=3)

        style = ttk.Style()
        try: style.theme_use("default")
        except Exception: pass
        nb = ttk.Notebook(self.root); nb.pack(fill="both", expand=True, padx=8, pady=6)
        self._tab_digest(nb)
        self._tab_write(nb)
        self._tab_query(nb)
        self._tab_ocr(nb)
        self._tab_asr(nb)
        self._tab_draw(nb)

        self._status = tk.Label(self.root, text="", bg=BG, fg=SUB,
                                font=("Segoe UI", 9), anchor="w")
        self._status.pack(fill="x", padx=12, pady=(0, 6))

    def _mk_output(self, parent, height=18):
        frm = tk.Frame(parent, bg=BG); frm.pack(fill="both", expand=True, padx=8, pady=6)
        sb = tk.Scrollbar(frm); sb.pack(side="right", fill="y")
        txt = tk.Text(frm, wrap="word", bg=PANEL, fg=FG, insertbackground=FG,
                      font=("Segoe UI", 11), yscrollcommand=sb.set, height=height,
                      padx=8, pady=6)
        txt.pack(side="left", fill="both", expand=True); sb.config(command=txt.yview)
        txt.tag_configure("src", foreground="#7ee787", font=("Consolas", 9))
        txt.tag_configure("me", foreground=ACC, font=("Segoe UI", 10, "bold"))
        return txt

    def _tab_digest(self, nb):
        f = tk.Frame(nb, bg=BG); nb.add(f, text="  📰 今日快報  ")
        bar = tk.Frame(f, bg=BG); bar.pack(fill="x", padx=8, pady=(8, 2))
        tk.Label(bar, text="日期：", bg=BG, fg=FG, font=("Segoe UI", 9)).pack(side="left")
        self._dg_date = tk.StringVar(value="—")
        self._dg_menu = tk.OptionMenu(bar, self._dg_date, "—")
        self._dg_menu.config(bg=PANEL, fg=FG, highlightthickness=0, font=("Segoe UI", 9), width=14)
        self._dg_menu.pack(side="left", padx=4)
        tk.Button(bar, text="↻ 更新清單", command=self._refresh_digests, bg=MUT, fg=FG,
                  borderwidth=0, cursor="hand2", padx=8, font=("Segoe UI", 9)).pack(side="left", padx=6)
        tk.Button(bar, text="複製", command=lambda: self._copy(self._dg_out), bg=MUT, fg=FG,
                  borderwidth=0, cursor="hand2", padx=8, font=("Segoe UI", 9)).pack(side="right")
        self._dg_out = self._mk_output(f, height=22)
        self._dg_out.insert("end", "每天 PubMed 頂尖期刊新文 → Gemma-4 整理的繁中早報（附 "
                            "PMID，引用前自行核對）。連上 box 會自動載入最新一天。\n\n", ("src",))
        self._dg_dates = []

    def _refresh_digests(self):
        if self.connected:
            self._send({"type": "digest_list"})

    def _load_digest(self, date):
        if date and date != "—":
            self._send({"type": "digest_get", "date": date})

    def _tab_write(self, nb):
        f = tk.Frame(nb, bg=BG); nb.add(f, text="  ✍️ 寫作  ")
        self._w_out = self._mk_output(f, height=16)
        bar = tk.Frame(f, bg=BG); bar.pack(fill="x", padx=8, pady=(0, 4))
        self._w_lit = tk.BooleanVar(value=False)
        tk.Checkbutton(bar, text="附文獻(RAG)", variable=self._w_lit, bg=BG, fg=FG,
                       selectcolor=PANEL, activebackground=BG, font=("Segoe UI", 9)).pack(side="left")
        tk.Button(bar, text="💾 存檔", command=lambda: self._save(self._w_out),
                  bg=MUT, fg=FG, borderwidth=0, cursor="hand2", padx=8,
                  font=("Segoe UI", 9)).pack(side="right")
        tk.Button(bar, text="🗑 清空", command=lambda: self._clear("write", self._w_out),
                  bg=MUT, fg=FG, borderwidth=0, cursor="hand2", padx=8,
                  font=("Segoe UI", 9)).pack(side="right", padx=6)
        inp = tk.Frame(f, bg=BG); inp.pack(fill="x", padx=8, pady=(0, 8))
        self._w_in = tk.Text(inp, height=3, wrap="word", bg=PANEL, fg=FG,
                             insertbackground=FG, font=("Segoe UI", 10))
        self._w_in.pack(side="left", fill="x", expand=True)
        self._w_in.bind("<Control-Return>", lambda e: (self._send_write(), "break"))
        tk.Button(inp, text="✍️ 生成\n(Ctrl+↵)", command=self._send_write, bg=BTN, fg="#fff",
                  borderwidth=0, cursor="hand2", padx=12, font=("Segoe UI", 10, "bold")
                  ).pack(side="left", fill="y", padx=(6, 0))
        self._w_out.insert("end", "給大綱 / bullet / 草稿 → 幫你草擬或潤稿（Intro/Methods/"
                           "Discussion/摘要/回覆審稿…）。數字與資料原樣保留，缺引用標"
                           "[CITATION NEEDED]，不杜撰。\n\n", ("src",))

    def _tab_query(self, nb):
        f = tk.Frame(nb, bg=BG); nb.add(f, text="  🔎 文獻查詢  ")
        self._q_out = self._mk_output(f, height=16)
        bar = tk.Frame(f, bg=BG); bar.pack(fill="x", padx=8, pady=(0, 4))
        tk.Button(bar, text="🗑 清空", command=lambda: self._clear("query", self._q_out),
                  bg=MUT, fg=FG, borderwidth=0, cursor="hand2", padx=8,
                  font=("Segoe UI", 9)).pack(side="right")
        inp = tk.Frame(f, bg=BG); inp.pack(fill="x", padx=8, pady=(0, 8))
        self._q_in = tk.Text(inp, height=2, wrap="word", bg=PANEL, fg=FG,
                             insertbackground=FG, font=("Segoe UI", 10))
        self._q_in.pack(side="left", fill="x", expand=True)
        self._q_in.bind("<Control-Return>", lambda e: (self._send_query(), "break"))
        tk.Button(inp, text="🔎 查詢\n(Ctrl+↵)", command=self._send_query, bg=BTN2, fg="#fff",
                  borderwidth=0, cursor="hand2", padx=12, font=("Segoe UI", 10, "bold")
                  ).pack(side="left", fill="y", padx=(6, 0))
        self._q_out.insert("end", "問臨床/研究問題 → 打機上 22M PubMed + 指引，答案附 "
                           "PMID。引用前務必自行核對；無支持會明說、不杜撰 PMID。\n\n", ("src",))

    def _tab_ocr(self, nb):
        f = tk.Frame(nb, bg=BG); nb.add(f, text="  🖼️ 文件 OCR  ")
        bar = tk.Frame(f, bg=BG); bar.pack(fill="x", padx=8, pady=(8, 2))
        tk.Button(bar, text="📁 選圖檔 OCR", command=self._pick_ocr, bg=BTN, fg="#fff",
                  borderwidth=0, cursor="hand2", padx=12, pady=6,
                  font=("Segoe UI", 10, "bold")).pack(side="left")
        tk.Button(bar, text="複製", command=lambda: self._copy(self._o_out),
                  bg=MUT, fg=FG, borderwidth=0, cursor="hand2", padx=8,
                  font=("Segoe UI", 9)).pack(side="right")
        tk.Button(bar, text="→ 當查詢", command=lambda: self._route_ocr("query"),
                  bg=MUT, fg=FG, borderwidth=0, cursor="hand2", padx=8,
                  font=("Segoe UI", 9)).pack(side="right", padx=6)
        tk.Button(bar, text="→ 丟進寫作", command=lambda: self._route_ocr("write"),
                  bg=MUT, fg=FG, borderwidth=0, cursor="hand2", padx=8,
                  font=("Segoe UI", 9)).pack(side="right")
        self._o_out = self._mk_output(f, height=20)
        self._o_out.insert("end", "選參考文獻掃描頁 / 圖表 / 表格 → 機上視覺 OCR（翻拍會"
                           "自動裁切）→ 文字。可複製或丟進寫作/查詢。\n\n", ("src",))

    def _tab_asr(self, nb):
        f = tk.Frame(nb, bg=BG); nb.add(f, text="  🎙️ 語音轉逐字稿  ")
        bar = tk.Frame(f, bg=BG); bar.pack(fill="x", padx=8, pady=(8, 2))
        tk.Button(bar, text="🎙️ 選音檔轉逐字稿", command=self._pick_asr, bg=BTN, fg="#fff",
                  borderwidth=0, cursor="hand2", padx=12, pady=6,
                  font=("Segoe UI", 10, "bold")).pack(side="left")
        tk.Button(bar, text="複製摘要", command=lambda: self._copy(self._a_sum),
                  bg=MUT, fg=FG, borderwidth=0, cursor="hand2", padx=8,
                  font=("Segoe UI", 9)).pack(side="right")
        tk.Button(bar, text="複製逐字稿", command=lambda: self._copy(self._a_tx),
                  bg=MUT, fg=FG, borderwidth=0, cursor="hand2", padx=8,
                  font=("Segoe UI", 9)).pack(side="right", padx=6)
        tk.Button(bar, text="→ 丟進寫作", command=self._route_asr,
                  bg=MUT, fg=FG, borderwidth=0, cursor="hand2", padx=8,
                  font=("Segoe UI", 9)).pack(side="right")
        tk.Label(f, text="逐字稿（Breeze 本機辨識）", bg=BG, fg=SUB,
                 font=("Segoe UI", 9, "bold"), anchor="w").pack(fill="x", padx=12, pady=(4, 0))
        self._a_tx = self._mk_output(f, height=10)
        self._a_tx.insert("end", "選本機錄音檔（wav/mp3/m4a…，演講／會議／訪談／口述）→ 機上"
                          "Breeze 轉逐字稿 → 自動出摘要。音訊不出這台機器。\n\n", ("src",))
        tk.Label(f, text="重點摘要（Gemma-4）", bg=BG, fg=SUB,
                 font=("Segoe UI", 9, "bold"), anchor="w").pack(fill="x", padx=12, pady=(2, 0))
        self._a_sum = self._mk_output(f, height=8)
        self._asr_busy = False

    def _pick_asr(self):
        if not self._guard() or self._asr_busy:
            return
        p = filedialog.askopenfilename(
            title="選要轉逐字稿的音檔",
            filetypes=[("音訊", "*.wav *.mp3 *.m4a *.aac *.flac *.ogg *.opus *.wma"),
                       ("所有檔案", "*.*")])
        if not p:
            return
        try:
            if os.path.getsize(p) > 300 * 1024 * 1024:
                self._set_status("音檔過大（>300MB）。", "#f0883e"); return
        except Exception as e:
            self._set_status(f"讀檔失敗: {e}", "#f85149"); return
        self._asr_busy = True
        self._a_tx.delete("1.0", "end"); self._a_sum.delete("1.0", "end")
        self._set_status(f"上傳中：{os.path.basename(p)}", "#a5d6ff")
        threading.Thread(target=self._do_asr_upload, args=(p,), daemon=True).start()

    def _do_asr_upload(self, path):
        try:
            with open(path, "rb") as fh:
                data = fh.read()
            self._send({"type": "research_asr_start", "name": os.path.basename(path),
                        "size": len(data)})
            CH = 256 * 1024
            for i in range(0, len(data), CH):
                self._send({"type": "research_asr_chunk",
                            "data": base64.b64encode(data[i:i + CH]).decode()})
            self._send({"type": "research_asr_end"})
            self.root.after(0, lambda: self._set_status("已上傳，轉錄中…", "#a5d6ff"))
        except Exception as e:
            self._asr_busy = False
            self.root.after(0, lambda: self._set_status(f"上傳失敗: {e}", "#f85149"))

    def _route_asr(self):
        """把摘要（或逐字稿）丟進寫作分頁輸入框。"""
        txt = self._a_sum.get("1.0", "end").strip() or self._a_tx.get("1.0", "end").strip()
        if txt and hasattr(self, "_w_in"):
            self._w_in.delete("1.0", "end")
            self._w_in.insert("end", txt)
            self._set_status("已丟進寫作分頁（切到 ✍️ 寫作）。", "#3fb950")

    def _tab_draw(self, nb):
        f = tk.Frame(nb, bg=BG); nb.add(f, text="  🎨 繪圖  ")
        inp = tk.Frame(f, bg=BG); inp.pack(fill="x", padx=8, pady=(8, 2))
        self._d_in = tk.Text(inp, height=2, wrap="word", bg=PANEL, fg=FG,
                             insertbackground=FG, font=("Segoe UI", 10))
        self._d_in.pack(side="left", fill="x", expand=True)
        self._d_in.bind("<Control-Return>", lambda e: (self._send_draw(), "break"))
        self._d_btn = tk.Button(inp, text="🎨 生成\n(Ctrl+↵)", command=self._send_draw,
                                bg="#a371f7", fg="#fff", borderwidth=0, cursor="hand2",
                                padx=12, font=("Segoe UI", 10, "bold"), activebackground="#b585ff")
        self._d_btn.pack(side="left", fill="y", padx=(6, 0))
        bar = tk.Frame(f, bg=BG); bar.pack(fill="x", padx=8, pady=(0, 4))
        tk.Button(bar, text="💾 存圖", command=self._save_draw, bg=MUT, fg=FG,
                  borderwidth=0, cursor="hand2", padx=8, font=("Segoe UI", 9)).pack(side="right")
        self._d_hint = tk.Label(bar, text="示意圖 / 衛教插畫 / 論文示意（中文描述即可，26B 會展開）",
                                bg=BG, fg=SUB, font=("Segoe UI", 8)); self._d_hint.pack(side="left")
        canvfrm = tk.Frame(f, bg=PANEL); canvfrm.pack(fill="both", expand=True, padx=8, pady=(0, 8))
        self._d_img_lbl = tk.Label(canvfrm, bg=PANEL, fg=SUB,
                                   text="（生成的圖會顯示在這）")
        self._d_img_lbl.pack(expand=True)
        self._d_img = None          # tk.PhotoImage keep-ref 防 GC
        self._d_b64 = ""            # 原始 PNG b64 供存檔

    # ── 動作 ──────────────────────────────────────────────────────────────
    def _send_write(self):
        q = self._w_in.get("1.0", "end").strip()
        if not q or not self._guard():
            return
        self._w_in.delete("1.0", "end")
        self._active = "write"
        self._w_out.insert("end", f"\n▶ {q}\n", ("me",))
        self._w_out.see("end")
        self._hist["write"].append({"role": "user", "content": q})
        self._send({"type": "research_ask", "text": q, "mode": "write",
                    "use_lit": bool(self._w_lit.get()),
                    "history": self._hist["write"][-10:]})
        self._set_status("寫作生成中…（長輸出，稍候）", "#a5d6ff")

    def _send_query(self):
        q = self._q_in.get("1.0", "end").strip()
        if not q or not self._guard():
            return
        self._q_in.delete("1.0", "end")
        self._active = "query"
        self._q_out.insert("end", f"\n▶ {q}\n", ("me",))
        self._q_out.see("end")
        self._hist["query"].append({"role": "user", "content": q})
        self._send({"type": "research_ask", "text": q, "mode": "query",
                    "use_lit": True, "history": self._hist["query"][-10:]})
        self._set_status("檢索 PubMed + 指引中…", "#a5d6ff")

    def _pick_ocr(self):
        if not self._guard() or self._ocr_busy:
            return
        p = filedialog.askopenfilename(
            title="選要 OCR 的圖/掃描頁",
            filetypes=[("影像", "*.jpg *.jpeg *.png *.bmp *.tif *.tiff *.webp"),
                       ("所有檔案", "*.*")])
        if not p:
            return
        try:
            if os.path.getsize(p) > 30 * 1024 * 1024:
                self._set_status("影像過大（>30MB）。", "#f0883e"); return
        except Exception as e:
            self._set_status(f"讀檔失敗: {e}", "#f85149"); return
        self._ocr_busy = True
        self._set_status(f"OCR 中：{os.path.basename(p)}（讀圖，稍候）", "#a5d6ff")
        threading.Thread(target=self._do_ocr_upload, args=(p,), daemon=True).start()

    def _do_ocr_upload(self, path):
        try:
            with open(path, "rb") as fh:
                data = fh.read()
            self._send({"type": "doc_ocr_start", "name": os.path.basename(path),
                        "size": len(data)})
            CH = 256 * 1024
            for i in range(0, len(data), CH):
                self._send({"type": "doc_ocr_chunk",
                            "data": base64.b64encode(data[i:i + CH]).decode()})
            self._send({"type": "doc_ocr_end"})
        except Exception as e:
            self._ocr_busy = False
            self.root.after(0, lambda: self._set_status(f"上傳失敗: {e}", "#f85149"))

    def _send_draw(self):
        q = self._d_in.get("1.0", "end").strip()
        if not q or not self._guard():
            return
        self._d_btn.config(state="disabled")
        self._d_img_lbl.config(image="", text="生成中…（SD 在 iGPU，約 10–40s）")
        self._set_status("繪圖生成中…", "#a5d6ff")
        self._send({"type": "draw_request", "prompt": q})

    def _save_draw(self):
        if not self._d_b64:
            return
        p = filedialog.asksaveasfilename(defaultextension=".png",
                                         filetypes=[("PNG", "*.png")])
        if not p:
            return
        try:
            with open(p, "wb") as f:
                f.write(base64.b64decode(self._d_b64))
            self._set_status(f"已存：{os.path.basename(p)}", "#3fb950")
        except Exception as e:
            self._set_status(f"存圖失敗: {e}", "#f85149")

    def _route_ocr(self, dest):
        t = self._o_out.get("1.0", "end").strip()
        if not t:
            return
        box = self._w_in if dest == "write" else self._q_in
        box.insert("end", ("\n" if box.get("1.0", "end").strip() else "") + t)
        self._set_status(f"已把 OCR 文字丟進{'寫作' if dest=='write' else '查詢'}輸入框。", "#3fb950")

    # ── 研究模式（地端 AI）開關 ────────────────────────────────────────────
    def _toggle_mode(self):
        if not self.connected:
            self._set_status("未連線 box，無法切換研究模式。", "#f85149"); return
        if self._edge_on:
            self._send({"type": "exit_edge_ai"})
            self._set_status("退出研究模式中…", SUB)
        else:
            self._send({"type": "enter_edge_ai"})
            self._set_status("進入研究模式中…（切換地端 AI）", "#a5d6ff")

    def _apply_edge_state(self, on):
        self._edge_on = bool(on)
        if self._edge_on:
            self._mode_lbl.config(text="研究模式：開（地端 AI）", fg="#3fb950")
            self._mode_btn.config(text="■ 退出研究模式", bg=MUT)
        else:
            self._mode_lbl.config(text="研究模式：關（尚未進入地端 AI）", fg="#f0883e")
            self._mode_btn.config(text="▶ 進入研究模式（地端 AI）", bg=BTN)

    # ── 共用小工具 ─────────────────────────────────────────────────────────
    def _guard(self):
        if not self.connected:
            self._set_status("未連線 OPPA box。確認機器開著、網路線接好，按「重連」。", "#f85149")
            return False
        if not self._edge_on:
            self._set_status("研究功能需先進入研究模式（右上「進入研究模式」）— 避免看診中誤用。",
                             "#f0883e")
            return False
        return True

    def _save(self, txt):
        content = txt.get("1.0", "end-1c")
        if not content.strip():
            return
        p = filedialog.asksaveasfilename(defaultextension=".md",
            filetypes=[("Markdown", "*.md"), ("純文字", "*.txt")])
        if not p:
            return
        try:
            with open(p, "w", encoding="utf-8") as f:
                f.write(content)
            self._set_status(f"已存：{os.path.basename(p)}", "#3fb950")
        except Exception as e:
            self._set_status(f"存檔失敗: {e}", "#f85149")

    def _copy(self, txt):
        try:
            self.root.clipboard_clear()
            self.root.clipboard_append(txt.get("1.0", "end-1c"))
            self._set_status("已複製到剪貼簿。", "#3fb950")
        except Exception:
            pass

    def _clear(self, key, txt):
        txt.delete("1.0", "end"); self._hist[key] = []

    def _set_status(self, text, color=SUB):
        try: self._status.config(text=text, fg=color)
        except Exception: pass

    # ── WebSocket ─────────────────────────────────────────────────────────
    def _send(self, obj):
        try:
            if self.ws:
                self.ws.send(json.dumps(obj))
        except Exception as e:
            self.root.after(0, lambda: self._set_status(f"送出失敗: {e}", "#f85149"))

    def _reconnect(self):
        try:
            if self.ws:
                self.ws.close()
        except Exception:
            pass

    def _ws_loop(self):
        while True:
            url = self._host.get().strip() or DEFAULT_WS
            try:
                self.ws = websocket.WebSocketApp(
                    url, on_open=self._on_open, on_message=self._on_message,
                    on_close=self._on_close, on_error=lambda w, e: None)
                self.ws.run_forever(ping_interval=30, ping_timeout=10)
            except Exception:
                pass
            self.connected = False
            self._q.put(("conn", False))
            time.sleep(3)

    def _on_open(self, ws):
        self.connected = True
        self._q.put(("conn", True))
        try:
            ws.send(json.dumps({"type": "client_hello", "role": "research"}))
            ws.send(json.dumps({"type": "get_edge_ai_state"}))   # 同步研究模式狀態
            ws.send(json.dumps({"type": "digest_list"}))         # 載入今日快報清單
        except Exception:
            pass

    def _on_close(self, ws, *a):
        self.connected = False; self._q.put(("conn", False))

    def _on_message(self, ws, raw):
        try:
            m = json.loads(raw)
        except Exception:
            return
        self._q.put(("msg", m))            # 交給 tk 主緒處理（tk 非執行緒安全）

    # ── tk 主緒訊息幫浦 ────────────────────────────────────────────────────
    def _pump(self):
        try:
            while True:
                kind, payload = self._q.get_nowait()
                if kind == "conn":
                    self._dot.config(fg="#3fb950" if payload else "#f85149")
                    self._conn.config(text="已連線" if payload else "離線")
                    if not payload:
                        self._apply_edge_state(False)
                        self._set_status("離線 — 確認 box 開著、線接好。", "#f0883e")
                elif kind == "msg":
                    self._handle(payload)
        except queue.Empty:
            pass
        self.root.after(60, self._pump)

    def _out_for(self, mode):
        return self._w_out if mode == "write" else self._q_out

    @staticmethod
    def _fmt_src(it):
        """research_sources item(dict:tag/title/source/excerpt 或 str) → 一行可讀來源。"""
        if isinstance(it, str):
            return it.strip()
        tag = (it.get("tag") or "").strip()
        title = (it.get("title") or "").strip()
        src = (it.get("source") or "").strip()
        head = f"[{tag}] " if tag else ""
        tail = f" — {src}" if src and src != title else ""
        return (head + title + tail).strip()

    @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 _handle(self, m):
        t = m.get("type")
        if t == "digest_list":
            self._dg_dates = m.get("dates") or []
            try:
                menu = self._dg_menu["menu"]; menu.delete(0, "end")
                if not self._dg_dates:
                    self._dg_date.set("（尚無快報）")
                    menu.add_command(label="（尚無快報）",
                                     command=lambda: self._dg_date.set("（尚無快報）"))
                else:
                    for d in self._dg_dates:
                        menu.add_command(label=d, command=lambda v=d: (
                            self._dg_date.set(v), self._load_digest(v)))
                    self._dg_date.set(self._dg_dates[0])
                    self._load_digest(self._dg_dates[0])   # 自動載入最新
            except Exception:
                pass
        elif t == "digest_content":
            try:
                self._dg_out.delete("1.0", "end")
                self._insert_pmid_links(self._dg_out, m.get("content") or "（此日無快報內容）")
                self._dg_out.see("1.0")
            except Exception:
                pass
        elif t == "edge_ai_state":
            self._apply_edge_state(m.get("on"))
            self._set_status("已進入研究模式，可以開始。" if self._edge_on
                             else "已退出研究模式。", "#3fb950" if self._edge_on else SUB)
        elif t == "edge_ai_error":
            self._apply_edge_state(False)
            self._set_status(f"研究模式無法開啟：{m.get('msg','')}", "#f0883e")
        elif t == "research_start":
            self._active = m.get("mode") or self._active
            self._out_for(self._active).insert("end", "\n")
        elif t == "research_chunk":
            if self._active:
                o = self._out_for(self._active)
                o.insert("end", m.get("delta", "")); o.see("end")
        elif t == "research_end":
            mode = self._active
            if mode:
                out = self._out_for(mode)
                full = out.get("1.0", "end")
                # 收本輪 assistant 內容進 history（取最後一段 ▶ 之後）
                self._hist.setdefault(mode, [])
                self._hist[mode].append({"role": "assistant",
                                         "content": full.rsplit("▶", 1)[-1][:4000]})
                out.insert("end", f"\n— {m.get('seconds',0)}s —\n")
                out.see("end")
            self._set_status("完成。", "#3fb950")
            self._active = None
        elif t == "research_sources":
            items = m.get("items") or []
            if items:
                o = self._out_for("query")
                o.insert("end", "\n📚 來源（引用前務必自行核對）：\n", ("src",))
                seen = set()
                for it in items[:15]:
                    line = self._fmt_src(it)
                    if line and line not in seen:
                        seen.add(line)
                        o.insert("end", f"  • {line}\n", ("src",))
                o.see("end")
        elif t == "research_busy":
            self._set_status("box 正在看診/生成，稍候再試。", "#f0883e"); self._active = None
        elif t == "research_error":
            self._set_status(f"錯誤：{m.get('msg','')}", "#f85149"); self._active = None
        elif t == "draw_start":
            self._set_status("繪圖生成中…", "#a5d6ff")
        elif t == "draw_result":
            self._d_btn.config(state="normal")
            b64 = m.get("b64", "")
            try:
                self._d_img = tk.PhotoImage(data=b64)   # PNG b64（Tk 8.6+）
                self._d_b64 = b64
                self._d_img_lbl.config(image=self._d_img, text="")
                self._set_status("繪圖完成，可存圖。", "#3fb950")
            except Exception as e:
                self._d_img_lbl.config(image="", text="（圖片顯示失敗）")
                self._set_status(f"圖片解碼失敗: {e}", "#f85149")
        elif t == "draw_busy":
            self._d_btn.config(state="normal")
            self._set_status("box 忙碌中，稍候再生成。", "#f0883e")
        elif t == "draw_error":
            self._d_btn.config(state="normal")
            self._d_img_lbl.config(image="", text="（生成失敗）")
            self._set_status(f"繪圖失敗：{m.get('msg','')}", "#f0883e")
        elif t == "doc_ocr_ready":
            pass
        elif t == "doc_ocr_result":
            self._ocr_busy = False
            if m.get("ok"):
                self._o_out.delete("1.0", "end")
                self._o_out.insert("end", m.get("text", ""))
                self._set_status("OCR 完成。可複製或丟進寫作/查詢。", "#3fb950")
            else:
                self._set_status(f"OCR 失敗：{m.get('msg','')}", "#f0883e")
        elif t == "research_asr_ready":
            pass
        elif t == "research_asr_status":
            self._set_status(m.get("msg", "轉錄中…"), "#a5d6ff")
        elif t == "research_asr_transcript":
            self._a_tx.delete("1.0", "end")
            self._a_tx.insert("end", m.get("text", ""))
            self._set_status(f"逐字稿完成（{m.get('seconds','?')}s），摘要生成中…", "#a5d6ff")
        elif t == "research_asr_summary_start":
            self._a_sum.delete("1.0", "end")
        elif t == "research_asr_summary_chunk":
            self._a_sum.insert("end", m.get("delta", "")); self._a_sum.see("end")
        elif t == "research_asr_summary_end":
            self._asr_busy = False
            self._set_status("完成：逐字稿 + 摘要。可複製或丟進寫作。", "#3fb950")
        elif t == "research_asr_error":
            self._asr_busy = False
            self._set_status(f"語音轉錄失敗：{m.get('msg','')}", "#f0883e")

    def run(self):
        self.root.mainloop()


if __name__ == "__main__":
    ResearchWidget().run()
