import tkinter as tk
from tkinter import scrolledtext, messagebox, simpledialog, ttk, font as tkfont
import requests
import json
import datetime
import webbrowser
import sys, os
import io
import time
import ctypes  # ← これを使います
import subprocess
import re
from urllib.parse import urlsplit
import ipaddress
from PIL import Image, ImageTk

# 追加: 非同期実行・ロギング関連
import logging
import threading
from concurrent.futures import ThreadPoolExecutor

# ロギング初期化（必要に応じてレベル調整）
logging.basicConfig(level=logging.INFO)

# 共有スレッドプール / HTTP タイムアウト（接続, 読み取り）
EXEC = ThreadPoolExecutor(max_workers=4)
HTTP_TIMEOUT = (3, 10)


# ★ 追加：DPI Awareness（Tk を作る前に呼ぶ）
def enable_dpi_awareness():
    if hasattr(ctypes, "windll"):
        try:
            # Windows 10 1607+ (Per-Monitor v2)
            ctypes.windll.user32.SetProcessDpiAwarenessContext(-4)
            return
        except Exception:
            pass
        try:
            # Windows 8.1+
            ctypes.windll.shcore.SetProcessDpiAwareness(2)  # PER_MONITOR_DPI_AWARE
            return
        except Exception:
            pass
        try:
            # Vista+
            ctypes.windll.user32.SetProcessDPIAware()
        except Exception:
            pass


enable_dpi_awareness()  # ← この1行を関数定義のすぐ下に入れる


try:
    ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("com.jigyodan.zaitaku.v4")
except Exception:
    pass


def resource_path(rel):
    if hasattr(sys, "_MEIPASS"):  # PyInstaller が一時展開する場所
        return os.path.join(sys._MEIPASS, rel)
    return os.path.join(os.path.abspath("."), rel)


CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".jigyodan_config.json")
ADMIN_PASSWORD = "admin"


class Api:
    """requests.Session を用いた通信ラッパー。Keep-Alive とタイムアウトを統一。"""

    def __init__(self, base_url: str, host_header: str | None = None):
        self.sess = requests.Session()
        self.base_url = base_url
        self.host_header = host_header or ""

    def post_multipart(self, url: str, fields: dict, files: dict | None = None):
        files_param = {k: (None, "" if v is None else str(v)) for k, v in fields.items()}
        if files:
            files_param.update(files)

        headers = {}
        try:
            u = urlsplit(url)
            hostname = u.hostname or ""
            scheme = (u.scheme or "").lower()
            # Host ヘッダ差し替えは http のときのみ（https では SNI 不整合の恐れ）
            if scheme == "http":
                if self.host_header:
                    headers["Host"] = self.host_header
                else:
                    try:
                        ipaddress.ip_address(hostname)
                        headers["Host"] = "jigyodan.sakura.ne.jp"
                    except Exception:
                        pass
        except Exception:
            pass

        logging.debug(f"POST {url} fields={list(fields.keys())} files={list((files or {}).keys())}")
        return self.sess.post(url, files=files_param, headers=headers, timeout=HTTP_TIMEOUT)


def load_config():
    try:
        if os.path.exists(CONFIG_PATH):
            with open(CONFIG_PATH, "r", encoding="utf-8") as f:
                return json.load(f)
    except Exception:
        pass
    return {}


def save_config(cfg):
    try:
        with open(CONFIG_PATH, "w", encoding="utf-8") as f:
            json.dump(cfg, f, ensure_ascii=False, indent=2)
    except Exception:
        pass


def get_system_volume_serial():
    drive = os.getenv("SystemDrive", "C:") + "\\"
    if hasattr(ctypes, "windll"):
        try:
            volume_name_buffer = ctypes.create_unicode_buffer(256)
            filesystem_name_buffer = ctypes.create_unicode_buffer(256)
            serial_number = ctypes.c_uint()
            max_component_length = ctypes.c_uint()
            file_system_flags = ctypes.c_uint()
            result = ctypes.windll.kernel32.GetVolumeInformationW(
                drive,
                volume_name_buffer,
                len(volume_name_buffer),
                ctypes.byref(serial_number),
                ctypes.byref(max_component_length),
                ctypes.byref(file_system_flags),
                filesystem_name_buffer,
                len(filesystem_name_buffer),
            )
            if result:
                return f"{serial_number.value:08X}"
        except Exception:
            pass

    try:
        completed = subprocess.run(
            ["cmd", "/c", "vol", drive],
            capture_output=True,
            text=True,
            timeout=5,
        )
        if completed.returncode == 0 and completed.stdout:
            match = re.search(r"([0-9A-Fa-f]{4}-[0-9A-Fa-f]{4})", completed.stdout)
            if match:
                return match.group(1).replace("-", "").upper()
    except Exception:
        pass

    return ""


class SettingsDialog(tk.Toplevel):
    def __init__(self, master, cfg):
        super().__init__(master)
        self.title("設定")
        self.resizable(False, False)
        self.grab_set()
        self.cfg = cfg.copy()

        frm = tk.Frame(self)
        frm.pack(padx=12, pady=12)

        row = 0
        tk.Label(frm, text="接続先").grid(row=row, column=0, sticky="e", padx=6, pady=6)
        self.base_choices = [
            "https://jigyodan.sakura.ne.jp/zaitakukanri_honbu/",
            "http://49.212.200.138/zaitakukanri_honbu/public/",
            "http://localhost/zaitakukanri_honbu/",
        ]
        self.var_base = tk.StringVar(value=self.cfg.get("base_url", self.base_choices[0]))
        self.opt_base = tk.OptionMenu(frm, self.var_base, *self.base_choices)
        self.opt_base.config(width=38)
        self.opt_base.grid(row=row, column=1, padx=6, pady=6, sticky="w")

        row += 1
        tk.Label(frm, text="ユーザー名").grid(row=row, column=0, sticky="e", padx=6, pady=6)
        self.ent_user = tk.Entry(frm, width=28)
        self.ent_user.grid(row=row, column=1, padx=6, pady=6)
        self.ent_user.insert(0, cfg.get("user_id", ""))

        row += 1
        tk.Label(frm, text="パスワード").grid(row=row, column=0, sticky="e", padx=6, pady=6)
        self.ent_pass = tk.Entry(frm, show="*", width=28)
        self.ent_pass.grid(row=row, column=1, padx=6, pady=6)
        self.ent_pass.insert(0, cfg.get("password", ""))

        row += 1
        tk.Label(frm, text="ボリューム番号").grid(row=row, column=0, sticky="e", padx=6, pady=6)
        self.ent_vol = tk.Entry(frm, width=28)
        self.ent_vol.grid(row=row, column=1, padx=6, pady=6)
        volume_value = cfg.get("volume_no", "")
        auto_volume = get_system_volume_serial()
        if auto_volume:
            volume_value = auto_volume
        self.ent_vol.insert(0, volume_value)

        row += 1
        tk.Label(frm, text="シリアル番号").grid(row=row, column=0, sticky="e", padx=6, pady=6)
        self.ent_ser = tk.Entry(frm, width=28)
        self.ent_ser.grid(row=row, column=1, padx=6, pady=6)
        self.ent_ser.insert(0, cfg.get("serial_no", ""))

        row += 1
        tk.Label(frm, text="Hostヘッダ(任意)").grid(row=row, column=0, sticky="e", padx=6, pady=6)
        self.ent_host = tk.Entry(frm, width=28)
        self.ent_host.grid(row=row, column=1, padx=6, pady=6)
        self.ent_host.insert(0, cfg.get("host_header", ""))

        row += 1
        tk.Label(frm, text="カメラ番号(任意)").grid(row=row, column=0, sticky="e", padx=6, pady=6)
        self.ent_cam = tk.Entry(frm, width=28)
        self.ent_cam.grid(row=row, column=1, padx=6, pady=6)
        self.ent_cam.insert(0, str(cfg.get("camera_index", 0)))

        row += 1
        btns = tk.Frame(self)
        btns.pack(padx=12, pady=(0, 12), fill="x")
        tk.Button(btns, text="保存", command=self.on_save).pack(side="right", padx=4)
        tk.Button(btns, text="閉じる", command=self.destroy).pack(side="right", padx=4)

    def on_save(self):
        self.cfg["base_url"] = self.var_base.get().strip()
        self.cfg["user_id"] = self.ent_user.get().strip()
        self.cfg["password"] = self.ent_pass.get().strip()
        self.cfg["volume_no"] = self.ent_vol.get().strip()
        self.cfg["serial_no"] = self.ent_ser.get().strip()
        self.cfg["host_header"] = self.ent_host.get().strip()
        try:
            self.cfg["camera_index"] = int(self.ent_cam.get().strip())
        except Exception:
            self.cfg["camera_index"] = 0

        self.master._temp_password = self.cfg["password"]
        self.master.base_url = self.cfg["base_url"]
        self.master.host_header = self.cfg.get("host_header", "")
        self.master.camera_index = self.cfg.get("camera_index", 0)
        # Api 側へも反映
        try:
            self.master.api.base_url = self.master.base_url
            self.master.api.host_header = self.master.host_header
        except Exception:
            pass
        save_config(self.cfg)
        self.destroy()


class MainWindow(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("在宅就労管理システム Ver.4.0")
        self.configure(bg="#bfcddb")

        # 既定フォントを統一（IMEプレエディットの描画サイズも安定化）
        self._apply_base_fonts()

        # --- アイコン設定 ---
        try:
            # 1) .ico（マルチサイズ ICO 推奨：16,24,32,48,64,128,256 を内包）
            self.iconbitmap(resource_path("zaitaku.ico"))
        except Exception as e:
            print("ICO読み込み失敗:", e)

        # 2) 追加：PNGも登録（タスクバー・Alt+Tab でのぼやけ対策）
        try:
            # ここでは 16 / 32 / 48 / 256px を例示（必要に応じて増減OK）
            png16 = ImageTk.PhotoImage(Image.open(resource_path("icon_16.png")))
            png32 = ImageTk.PhotoImage(Image.open(resource_path("icon_32.png")))
            png48 = ImageTk.PhotoImage(Image.open(resource_path("icon_48.png")))
            png256 = ImageTk.PhotoImage(Image.open(resource_path("icon_256.png")))
            # 複数渡すと OS/Tk が最適サイズを選ぶ
            self.wm_iconphoto(True, png256, png48, png32, png16)
            # 参照キープ（ガベコレ防止）
            self._icon_refs = (png16, png32, png48, png256)
        except Exception as e:
            print("PNGアイコン設定失敗:", e)

        # 固定画像サイズ/ボタンサイズ
        self.IMG_W = 160
        self.IMG_H = 120
        self.BTN_WIDE = 16
        self.BTN_HEIGHT = 2

        # 画面サイズは横固定・縦のみ可変
        self.geometry("900x470")
        self.resizable(False, False)

        # 状態
        cfg0 = load_config()
        self.base_url = cfg0.get("base_url", "https://jigyodan.sakura.ne.jp/zaitakukanri_honbu/")
        self.token = None
        self.last_chat_date = ""
        self.logged_in = False
        self._poll_after_id = None
        self._temp_password = ""
        self.host_header = cfg0.get("host_header", "")
        self.camera_index = int(cfg0.get("camera_index", 0))

        # 共有 API セッション（Keep-Alive）
        self.api = Api(self.base_url, self.host_header)

        # ポーリング指数バックオフ関連
        self._poll_base_delay = 5000  # 5s
        self._poll_max_delay = 60000  # 60s
        self._poll_current_delay = self._poll_base_delay

        self.middle_interval_minutes = 30
        self._middle_after_id = None
        self.keyboard_interval_minutes = 30
        self.mouse_interval_minutes = 30
        self._kbd_after_id = None
        self._mouse_after_id = None
        self.keyboard_flag = "2"
        self.mouse_flag = "2"
        self._kbd_listener = None
        self._mouse_listener = None

        self.last_capture_photo = None
        self.capture_display_label = None

        # 2カラム構成
        self.columnconfigure(0, weight=6, minsize=450)
        self.columnconfigure(1, weight=5, minsize=450)
        self.rowconfigure(0, weight=1)
        self.rowconfigure(1, weight=0)

        # ===== 左側 =====
        left = tk.Frame(self, bg="#bfcddb", bd=0, relief="flat", highlightthickness=0)
        left.grid(row=0, column=0, sticky="nsew", padx=(10, 6), pady=10)
        left.columnconfigure(0, weight=1)
        left.rowconfigure(0, weight=0, minsize=230)
        left.rowconfigure(1, weight=0, minsize=147)

        # 作業メモ（読み専用風のText）— 外枠も白、枠なし
        workspace_outer = tk.Frame(
            left,
            bg="white",
            bd=0,  # ← 枠線なし
            relief="flat",  # ← 立体効果なし
            highlightthickness=0,  # ← フォーカス枠なし
        )
        workspace_outer.grid(row=0, column=0, sticky="nsew", padx=4, pady=(4, 8))

        self.workspace = tk.Text(
            workspace_outer,
            bg="white",
            height=14,
            bd=0,
            relief="flat",  # ← 念のため追加
            highlightthickness=0,
            takefocus=True,
        )
        # 内側余白 5px（白が見える）
        self.workspace.pack(fill="both", expand=True, padx=5, pady=5)
        self.workspace.bind("<KeyPress>", self.on_readonly_text_keypress)
        self.workspace.bind("<Tab>", self.focus_next_widget)
        self.workspace.bind("<Shift-Tab>", self.focus_prev_widget)

        # 外部リンク
        link_frame = tk.Frame(left, bg="#bfcddb", bd=0, relief="flat", highlightthickness=0)
        link_frame.grid(row=1, column=0, sticky="nsew")
        link_frame.columnconfigure(0, weight=1)
        link_frame.rowconfigure(1, weight=1)

        links_header = tk.Frame(link_frame, bg="#bfcddb", bd=0, relief="flat", highlightthickness=0)
        links_header.grid(row=0, column=0, sticky="ew", padx=2, pady=(0, 4))
        links_header.columnconfigure(0, weight=1)
        tk.Label(
            links_header,
            text="参考外部リンク",
            font=("Meiryo", 12, "bold"),
            bg="#bfcddb",
            anchor="w",
        ).grid(row=0, column=0, sticky="w")
        tk.Button(
            links_header,
            text="日報",
            width=self.BTN_WIDE,
            height=self.BTN_HEIGHT,
            command=self.open_report,
        ).grid(row=0, column=1, padx=(10, 0))

        # ▼ ここから内側余白つきのパネル構成（白で統一）
        links_panel = tk.Frame(link_frame, bg="#bfcddb", bd=0, highlightthickness=0)
        links_panel.grid(row=1, column=0, sticky="nsew")

        # 外枠も白にする（薄い枠線が欲しければ bd=1, relief="solid" を維持）
        outer = tk.Frame(links_panel, bg="white", bd=0, highlightthickness=0, relief="flat")
        outer.pack(fill="both", expand=True)
        # 内枠（余白を白で表示）
        inner = tk.Frame(outer, bg="white")
        inner.pack(fill="both", expand=True, padx=8, pady=8)

        # Listbox も白に
        self.links_list = tk.Listbox(inner, height=5, bd=0, highlightthickness=0, bg="white")
        self.links_list.pack(fill="both", expand=True)
        self.links_list.bind("<Double-Button-1>", self.open_selected_link)
        self._links_map = {}

        # ===== 右側 =====
        right = tk.Frame(self, bg="#bfcddb", bd=0, relief="flat", highlightthickness=0)
        right.grid(row=0, column=1, sticky="nsew", padx=0, pady=0)
        right.columnconfigure(0, weight=1)
        right.rowconfigure(0, weight=0, minsize=220)  # 上段やや固定
        right.rowconfigure(1, weight=1, minsize=self.IMG_H + 40)  # 下段に余りを渡す

        # 上段（チャット）
        upper_right = tk.Frame(right, bg="#bfcddb", bd=0, relief="flat", highlightthickness=0)
        upper_right.grid(row=0, column=0, sticky="nsew")
        upper_right.columnconfigure(0, weight=1)
        upper_right.rowconfigure(0, weight=0)

        header = tk.Frame(upper_right, bg="#bfcddb", bd=0, relief="flat", highlightthickness=0)
        header.grid(row=0, column=0, sticky="ew", padx=4, pady=(8, 6))
        WIDE_L = self.BTN_WIDE + 1
        WIDE_S = max(8, self.BTN_WIDE - 10)
        self.lbl_login = tk.Button(
            header,
            text="未ログイン",
            width=WIDE_L,
            height=self.BTN_HEIGHT,
            state="disabled",
            relief="raised",
        )
        self.btn_login = tk.Button(
            header, text="ログイン", width=WIDE_L, height=self.BTN_HEIGHT, command=self.toggle_login
        )
        self.btn_setting = tk.Button(
            header, text="設定", width=WIDE_S, height=self.BTN_HEIGHT, command=self.open_settings
        )
        self.update_button = tk.Button(
            header,
            text="更新",
            width=WIDE_S,
            height=self.BTN_HEIGHT,
            command=self.fetch_messages_once_bg,
        )
        self.lbl_login.grid(row=0, column=0, padx=6)
        self.btn_login.grid(row=0, column=1, padx=6)
        self.btn_setting.grid(row=0, column=2, padx=6)
        self.update_button.grid(row=0, column=3, padx=6)

        # チャット表示欄 — 外枠も内側も白でフラット
        chat_frame_outer = tk.Frame(
            upper_right,
            bg="white",
            bd=0,  # 枠線なし
            relief="flat",  # フラットにする
            highlightthickness=0,  # フォーカス枠なし
        )
        chat_frame_outer.grid(row=1, column=0, sticky="nsew", padx=4, pady=4)

        self.chat_display = scrolledtext.ScrolledText(
            chat_frame_outer,
            wrap="word",
            height=12,
            bd=0,  # 枠線なし
            relief="flat",  # フラットにする
            highlightthickness=0,  # フォーカス枠なし
            bg="white",  # 背景を白に統一
        )
        self.chat_display.configure(state="normal", takefocus=True)
        self.chat_display.pack(fill="both", expand=True, padx=5, pady=5)

        self.chat_display.tag_configure("chat_admin", foreground="red", spacing3=8)
        self.chat_display.tag_configure("chat_user", foreground="blue", spacing3=8)
        self.chat_display.tag_configure("chat_default", spacing3=8)
        self.chat_display.bind("<KeyPress>", self.on_readonly_text_keypress)
        self.chat_display.bind("<Tab>", self.focus_next_widget)
        self.chat_display.bind("<Shift-Tab>", self.focus_prev_widget)

        input_row = tk.Frame(upper_right, bg="#bfcddb", bd=0, relief="flat", highlightthickness=0)
        input_row.grid(row=2, column=0, sticky="ew", pady=(0, 6), padx=(4, 0))
        input_row.columnconfigure(0, weight=1)

        # IMEプレエディットでの一時的な高さ拡張を防ぐ：
        # 固定高さの外枠に入れ、フォント行高から内側パディングを算出して中央寄せにする。
        try:
            # エントリ専用はやや小さめのフォントでプレエディットの見た目を安定化
            f = tkfont.Font(family="Meiryo", size=9)
            line_h = int(f.metrics("linespace") or 16)
        except Exception:
            f = None
            try:
                df = tkfont.nametofont("TkDefaultFont")
                line_h = int(df.metrics("linespace") or 16)
            except Exception:
                line_h = 16
        target_h = 28  # 固定高さ

        self.message_wrap = tk.Frame(input_row, height=target_h, bg="white")
        self.message_wrap.grid(row=0, column=0, sticky="ew")
        self.message_wrap.grid_propagate(False)  # 内部要求サイズで外枠が伸びない
        self.message_wrap.columnconfigure(0, weight=1)

        # フォントと行高をもとに内側パディングを決める
        pad_v = max(2, (target_h - line_h) // 2)

        # ttk.Entry スタイルで上下パディングを付与（高さは wrap が担保）
        style = ttk.Style()
        try:
            style.configure(
                "Msg.TEntry", padding=(4, pad_v, 4, pad_v)
            )  # (left, top, right, bottom)
        except Exception:
            pass

        if f is not None:
            self.message_entry = ttk.Entry(
                self.message_wrap, state="disabled", style="Msg.TEntry", font=f
            )
        else:
            self.message_entry = ttk.Entry(self.message_wrap, state="disabled", style="Msg.TEntry")

        # pack は幅のみ広げ、高さは外枠（wrap）が担保
        self.message_entry.pack(fill="x", expand=True)

        self.message_entry.bind("<Return>", self.send_message_event)
        self.send_button = tk.Button(
            input_row, text="送信", command=self.send_message, state="disabled", width=6
        )
        self.send_button.grid(row=0, column=1, padx=6)

        # 下段（画像＆ボタン）
        lower_right = tk.Frame(right, bg="#bfcddb", bd=0, relief="flat", highlightthickness=0)
        lower_right.grid(row=1, column=0, sticky="nsew", padx=0, pady=0)
        lower_right.columnconfigure(0, weight=1)
        lower_right.rowconfigure(0, weight=0)
        lower_right.rowconfigure(1, weight=0)

        biz = tk.Frame(lower_right, bg="#bfcddb", bd=0, relief="flat", highlightthickness=0)
        biz.grid(row=0, column=0, sticky="nsew")
        biz.columnconfigure(0, weight=0, minsize=self.IMG_W + 8)
        biz.columnconfigure(1, weight=0, minsize=220)
        biz.rowconfigure(0, weight=0, minsize=self.IMG_H)

        # 画像枠
        img_wrap = tk.Frame(
            biz,
            width=self.IMG_W + 8,
            height=self.IMG_H,
            bg="#bfcddb",
            bd=0,
            relief="flat",
            highlightthickness=0,
        )
        img_wrap.grid(row=0, column=0, sticky="nw", padx=(0, 8))
        img_wrap.grid_propagate(False)

        img_box = tk.Frame(img_wrap, bg="white", bd=0, relief="flat", highlightthickness=0)
        img_box.place(x=4, y=0, width=self.IMG_W, height=self.IMG_H)

        self.capture_display_label = tk.Label(
            img_box, bg="white", bd=0, relief="flat", highlightthickness=0
        )
        self.capture_display_label.pack(fill="both", expand=True)

        # 操作列
        side = tk.Frame(biz, bg="#bfcddb", bd=0, relief="flat", highlightthickness=0)
        side.grid(row=0, column=1, sticky="nsew")
        side.columnconfigure(0, weight=1)

        btn_row = tk.Frame(side, bg="#bfcddb", bd=0, relief="flat", highlightthickness=0)
        btn_row.grid(row=0, column=0, sticky="ew", pady=(2, 12))
        btn_row.columnconfigure(0, weight=1)
        btn_row.columnconfigure(1, weight=1)

        self.training_button = tk.Button(
            btn_row,
            text="作業開始",
            width=self.BTN_WIDE,
            height=self.BTN_HEIGHT,
            command=self.toggle_training,
        )
        self.training_button.grid(row=0, column=0, padx=(0, 8), sticky="ew")
        inquiry_btn = tk.Button(
            btn_row,
            text="連絡要求",
            width=self.BTN_WIDE,
            height=self.BTN_HEIGHT,
            command=self.send_inquiry,
        )
        inquiry_btn.grid(row=0, column=1, sticky="ew")

        status = tk.Frame(side, bg="#bfcddb", bd=0, relief="flat")
        status.grid(row=1, column=0, sticky="ew")
        tk.Label(status, text="開始時刻", anchor="w", bg="#bfcddb").grid(
            row=0, column=0, sticky="w", padx=2, pady=2
        )
        self.lbl_start_val = tk.Label(status, text="-", anchor="w", bg="#bfcddb")
        self.lbl_start_val.grid(row=0, column=1, sticky="w", padx=6, pady=2)
        tk.Label(status, text="終了時刻", anchor="w", bg="#bfcddb").grid(
            row=1, column=0, sticky="w", padx=2, pady=2
        )
        self.lbl_end_val = tk.Label(status, text="-", anchor="w", bg="#bfcddb")
        self.lbl_end_val.grid(row=1, column=1, sticky="w", padx=6, pady=2)
        tk.Label(status, text="経過時間", anchor="w", bg="#bfcddb").grid(
            row=2, column=0, sticky="w", padx=2, pady=2
        )
        self.lbl_time_val = tk.Label(status, text="-", anchor="w", bg="#bfcddb")
        self.lbl_time_val.grid(row=2, column=1, sticky="w", padx=6, pady=2)

        close_row = tk.Frame(lower_right, bg="#bfcddb", bd=0, relief="flat", highlightthickness=0)
        close_row.grid(row=1, column=0, sticky="e")
        tk.Button(close_row, text="閉じる", width=8, command=self.on_close).pack(
            side="right", padx=(0, 6), pady=(6, 0)
        )

        # フッター
        footer = tk.Frame(self, bg="#bfcddb")
        footer.grid(row=1, column=0, columnspan=2, sticky="ew")
        footer.columnconfigure(0, weight=1)
        tk.Label(
            footer,
            text="COPYRIGHT©NPO在宅就労支援事業団 All RIGHTS RESERVED.",
            font=("Meiryo", 9),
            bg="#bfcddb",
            anchor="center",
        ).grid(row=0, column=0, pady=(4, 6))

        self.set_logged_out_state()
        self.protocol("WM_DELETE_WINDOW", self.on_close)

    def _apply_base_fonts(self):
        try:
            for name in (
                "TkDefaultFont",
                "TkTextFont",
                "TkFixedFont",
                "TkMenuFont",
                "TkHeadingFont",
                "TkCaptionFont",
                "TkSmallCaptionFont",
                "TkIconFont",
                "TkTooltipFont",
            ):
                try:
                    tkfont.nametofont(name).configure(family="Meiryo", size=8)
                except Exception:
                    pass
        except Exception:
            pass

    # ------------- ヘルパー -------------
    def run_bg(self, work, on_done=None, on_err=None):
        """重い処理/通信を BG で実行し、結果は UI スレッドへ反映。"""

        def task():
            try:
                result = work()
            except Exception as e:
                logging.exception("Background task failed")
                if on_err:
                    try:
                        self.after(0, lambda: on_err(e))
                    except Exception:
                        pass
                return
            if on_done:
                try:
                    self.after(0, lambda: on_done(result))
                except Exception:
                    pass

        EXEC.submit(task)

    # ---------------- 通信 ----------------
    def post_multipart(self, url: str, fields: dict, files: dict | None = None):
        # 既存呼び出し互換ラッパー（Api に委譲）
        return self.api.post_multipart(url, fields, files)

    # カメラ1枚撮影
    def capture_camera(self):
        try:
            import cv2
        except Exception as e:
            self.display_message(
                f"[注意] カメラ未対応: opencv-python をインストールしてください ({e})"
            )
            return (None, None)

        cap = None
        try:
            idx = (
                int(self.camera_index)
                if isinstance(self.camera_index, int) or str(self.camera_index).isdigit()
                else 0
            )
            cap = cv2.VideoCapture(idx, cv2.CAP_DSHOW)
            if not cap.isOpened():
                cap = cv2.VideoCapture(idx)

            cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.IMG_W)
            cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.IMG_H)

            ok, frame = cap.read()
            for _ in range(10):
                if ok and frame is not None:
                    break
                time.sleep(0.2)
                ok, frame = cap.read()

            if not ok or frame is None:
                self.display_message("[注意] カメラから画像を取得できませんでした。")
                return (None, None)

            # JPEG 品質 80 でエンコード
            ok2, buf = cv2.imencode(
                ".jpg",
                frame,
                [int(cv2.IMWRITE_JPEG_QUALITY), 80],
            )
            if not ok2:
                self.display_message("[注意] 画像のエンコードに失敗しました。")
                return (None, None)

            bio = io.BytesIO(buf.tobytes())
            bio.seek(0)
            return (("camera.jpg", bio, "image/jpeg"), frame)
        except Exception as e:
            self.display_message(f"[注意] カメラ撮影に失敗: {e}")
            return (None, None)
        finally:
            try:
                if cap is not None:
                    cap.release()
            except Exception:
                pass

    # ---------------- UI / 動作 ----------------
    def open_settings(self):
        admin_pass = simpledialog.askstring(
            "管理者パスワード", "設定を開くにはパスワードを入力してください。", show="*"
        )
        if admin_pass is None:
            return
        if admin_pass != ADMIN_PASSWORD:
            messagebox.showerror("エラー", "パスワードが違います。")
            return
        SettingsDialog(self, load_config())

    def toggle_login(self):
        if not self.logged_in:
            self.login()
        else:
            self.logout()

    def login(self):
        cfg = load_config()
        user_id = (cfg.get("user_id") or "").strip()
        volume_no = (cfg.get("volume_no") or "").strip()
        serial_no = (cfg.get("serial_no") or "").strip()
        password = (self._temp_password or cfg.get("password") or "").strip()

        if not user_id or not password or not volume_no or not serial_no:
            self.display_message("設定からログイン情報を入力してください。")
            return

        def work():
            url = self.base_url.rstrip("/") + "/training_login.php"
            data = {
                "user_id": user_id,
                "user_password": password,
                "volume_no": volume_no,
                "serial_no": serial_no,
            }
            res = self.post_multipart(url, data)
            res.raise_for_status()
            return res.json()

        def on_done(js):
            if isinstance(js, dict) and isinstance(js.get("res"), str) and len(js["res"]) == 32:
                self.token = js["res"]

                # サーバからの間隔値
                try:
                    if "s" in js:
                        self.middle_interval_minutes = max(1, int(str(js["s"])))
                    if "k" in js:
                        self.keyboard_interval_minutes = max(1, int(str(js["k"])))
                    if "m" in js:
                        self.mouse_interval_minutes = max(1, int(str(js["m"])))
                except Exception:
                    logging.warning("failed to parse interval settings")

                self.logged_in = True
                self.lbl_login.config(text="ログイン中")
                self.btn_login.config(text="ログアウト")
                self.load_first_bg()

                self.message_entry.config(state="normal")
                self.send_button.config(state="normal")

                self.start_polling()
                self.display_message("[システム] ログインしました。")
                self.last_chat_date = ""
            else:
                err = (
                    js.get("error", "ログインに失敗しました。")
                    if isinstance(js, dict)
                    else "ログインに失敗しました。"
                )
                self.display_message(f"[エラー] {err}")

        def on_err(e):
            self.display_message(f"[エラー] ログイン通信失敗: {e}")

        self.run_bg(work, on_done, on_err)

    def logout(self):
        # ログアウト通知は非同期で投げる（UI は即時切替）
        if self.token:
            try:
                url = self.base_url.rstrip("/") + "/training_logout.php"
                self.run_bg(lambda: self.post_multipart(url, {"value": self.token}))
            except Exception:
                pass

        self.token = None
        self.logged_in = False
        self.lbl_login.config(text="未ログイン")
        self.btn_login.config(text="ログイン")
        self.notice_clear()
        self.links_list.delete(0, tk.END)
        self._links_map.clear()
        self.last_chat_date = ""

        self.stop_polling()
        self.message_entry.config(state="disabled")
        self.send_button.config(state="disabled")

        self.training_started = False
        self.lbl_start_val.config(text="-")
        self.lbl_end_val.config(text="-")
        self.lbl_time_val.config(text="-")
        if hasattr(self, "training_button"):
            self.training_button.config(text="作業開始")
        self.display_message("[システム] ログアウトしました。")

        self.stop_middle_timer()
        self.stop_keyboard_timer()
        self.stop_mouse_timer()
        self.stop_kbd_mouse_listeners()

    def set_logged_out_state(self):
        self.token = None
        self.logged_in = False
        self.lbl_login.config(text="未ログイン")
        self.btn_login.config(text="ログイン")
        self.message_entry.config(state="disabled")
        self.send_button.config(state="disabled")
        self.training_started = False
        self.training_start_time = None
        self.training_end_time = None

    def load_first_bg(self):
        if not self.token:
            return

        def work():
            url = self.base_url.rstrip("/") + "/first.php"
            data = {"value": self.token}
            res = self.post_multipart(url, data)
            try:
                res.raise_for_status()
            except requests.exceptions.HTTPError:
                if res.status_code == 404:
                    return None
                raise
            return res.json()

        def on_done(js):
            if not isinstance(js, dict):
                return
            notice = js.get("notice")
            try:
                self.workspace.config(state="normal")
                self.workspace.delete("1.0", tk.END)
                if notice:
                    self.workspace.insert(tk.END, str(notice))
            except Exception:
                pass

            self.links_list.delete(0, tk.END)
            self._links_map.clear()
            links = js.get("link")
            if isinstance(links, list):
                for idx, item in enumerate(links):
                    name = item.get("link_name") if isinstance(item, dict) else None
                    url2 = item.get("link_url") if isinstance(item, dict) else None
                    if name and url2:
                        self.links_list.insert(tk.END, name)
                        self._links_map[idx] = url2

        def on_err(e):
            self.display_message(f"エラー（初期表示）: {e}")

        self.run_bg(work, on_done, on_err)

    def open_report(self):
        if not self.token:
            self.display_message("[注意] ログインしてください。")
            return
        base = self.base_url.rstrip("/") + "/"
        webbrowser.open(base + f"report_edit.php?t={self.token}")

    def open_selected_link(self, event=None):
        sel = self.links_list.curselection()
        if not sel:
            return
        idx = sel[0]
        url = self._links_map.get(idx)
        if url:
            webbrowser.open(url)
        self.links_list.selection_clear(0, tk.END)

    def send_inquiry(self):
        if not self.token:
            self.display_message("[注意] ログインしてください。")
            return

        def work():
            url = self.base_url.rstrip("/") + "/training_inquiry.php"
            data = {"value": self.token}
            cam_data, frame = self.capture_camera()
            return {
                "frame": frame,
                "resp": self.post_multipart(url, data, {"file": cam_data} if cam_data else None),
            }

        def on_done(result):
            try:
                frame = result.get("frame")
                if frame is not None:
                    self.display_captured_image(frame)
                res = result.get("resp")
                res.raise_for_status()
                self.display_message("[システム] 問い合せを送信しました。")
            except requests.exceptions.RequestException as e:
                self.display_message(f"エラー（問い合せ）: {e}")

        self.run_bg(work, on_done)

    def toggle_training(self):
        if not self.token:
            self.display_message("[注意] ログインしてください。")
            return

        if not getattr(self, "training_started", False):
            # 開始
            def work_start():
                url = self.base_url.rstrip("/") + "/training_start.php"
                data = {"value": self.token}
                cam_data, frame = self.capture_camera()
                res = self.post_multipart(url, data, {"file": cam_data} if cam_data else None)
                return {"resp": res, "frame": frame}

            def on_done_start(result):
                res = result["resp"]
                frame = result.get("frame")
                if frame is not None:
                    self.display_captured_image(frame)
                try:
                    res.raise_for_status()
                    js = {}
                    try:
                        js = res.json()
                    except Exception:
                        js = {}
                    if (
                        isinstance(js, dict)
                        and isinstance(js.get("res"), str)
                        and len(js["res"]) == 32
                    ):
                        self.token = js["res"]
                        self.training_started = True
                        self.training_start_time = datetime.datetime.now()
                        self.lbl_start_val.config(
                            text=self.training_start_time.strftime("%Y-%m-%d %H:%M:%S")
                        )
                        self.lbl_end_val.config(text="-")
                        self.lbl_time_val.config(text="-")
                        self.training_button.config(text="作業終了")

                        self.start_middle_timer()
                        self.start_kbd_mouse_listeners()
                        self.start_keyboard_timer()
                        self.start_mouse_timer()
                    else:
                        self.display_message("[エラー] 作業開始に失敗しました。")
                except requests.exceptions.RequestException as e:
                    self.display_message(f"エラー（業務）: {e}")

            self.run_bg(work_start, on_done_start)
        else:
            # 終了
            def work_end():
                url = self.base_url.rstrip("/") + "/training_end.php"
                data = {"value": self.token}
                cam_data, frame = self.capture_camera()
                res = self.post_multipart(url, data, {"file": cam_data} if cam_data else None)
                return {"resp": res, "frame": frame}

            def on_done_end(result):
                res = result["resp"]
                frame = result.get("frame")
                if frame is not None:
                    self.display_captured_image(frame)
                try:
                    res.raise_for_status()
                    js = {}
                    try:
                        js = res.json()
                    except Exception:
                        js = {}
                    if (
                        isinstance(js, dict)
                        and isinstance(js.get("res"), str)
                        and len(js["res"]) == 32
                    ):
                        self.token = js["res"]
                        self.training_started = False
                        self.training_end_time = datetime.datetime.now()
                        self.lbl_end_val.config(
                            text=self.training_end_time.strftime("%Y-%m-%d %H:%M:%S")
                        )
                        if self.training_start_time and self.training_end_time:
                            delta = self.training_end_time - self.training_start_time
                            mins = int(delta.total_seconds() // 60)
                            self.lbl_time_val.config(text=f"{mins} 分")
                        self.training_button.config(text="作業開始")

                        self.stop_middle_timer()
                        self.stop_keyboard_timer()
                        self.stop_mouse_timer()
                        self.stop_kbd_mouse_listeners()
                    else:
                        self.display_message("[エラー] 作業終了に失敗しました。")
                except requests.exceptions.RequestException as e:
                    self.display_message(f"エラー（業務）: {e}")

            self.run_bg(work_end, on_done_end)

    def send_message_event(self, event):
        self.send_message()

    def send_message(self):
        message = self.message_entry.get()
        if not message:
            return
        self.message_entry.delete(0, tk.END)
        if not self.token:
            self.display_message("[注意] ログインしてください。")
            return

        def work():
            url = self.base_url.rstrip("/") + "/training_insert.php"
            data = {"value": self.token, "value2": self.last_chat_date, "value3": message}
            res = self.post_multipart(url, data)
            res.raise_for_status()
            # レスポンスは配列 or メッセージのみ
            try:
                return res.json()
            except json.JSONDecodeError:
                return None

        def on_done(response_data):
            if isinstance(response_data, list):
                for item in response_data:
                    user_name = item.get("user_name")
                    admin_name = item.get("admin_name")
                    insert_date = item.get("insert_date")
                    chat_text = item.get("chat_text")

                    author_name = user_name or admin_name
                    if author_name and "\u30b7\u30b9\u30c6\u30e0" in author_name:
                        continue

                    role = None
                    if user_name:
                        role = "user"
                    elif admin_name:
                        role = "admin"

                    display_text = ""
                    if author_name:
                        display_text += f"{author_name}>"
                    if insert_date:
                        display_text += f"{insert_date}\n"
                    if chat_text:
                        display_text += f"{chat_text}"
                    if display_text:
                        self.display_message(display_text, role=role)
            else:
                # サーバがメッセージのみ返したケース: 明示的に取得
                self.fetch_messages_once_bg()

        def on_err(e):
            self.display_message(f"エラー（送信）: {e}")

        self.run_bg(work, on_done, on_err)

    def _display_messages_list(self, response_data):
        if isinstance(response_data, list):
            for item in response_data:
                user_name = item.get("user_name")
                admin_name = item.get("admin_name")
                insert_date = item.get("insert_date")
                chat_text = item.get("chat_text")

                author_name = user_name or admin_name
                if author_name and "\u30b7\u30b9\u30c6\u30e0" in author_name:
                    continue

                role = None
                if user_name:
                    role = "user"
                elif admin_name:
                    role = "admin"

                display_text = ""
                if author_name:
                    display_text += f"{author_name}>"
                if insert_date:
                    display_text += f"{insert_date}\n"
                if chat_text:
                    display_text += f"{chat_text}"
                if display_text:
                    self.display_message(display_text, role=role)

    def _fetch_messages_work(self):
        url = self.base_url.rstrip("/") + "/training_update.php"
        data = {"value": self.token, "value2": self.last_chat_date, "value3": ""}
        res = self.post_multipart(url, data)
        res.raise_for_status()
        return res.json()

    def fetch_messages_once_bg(self):
        if not self.token:
            return

        def on_done(response_data):
            self._display_messages_list(response_data)

        def on_err(e):
            self.display_message(f"エラー（受信）: {e}")

        self.run_bg(self._fetch_messages_work, on_done, on_err)

    def start_polling(self):
        self.stop_polling()
        self._poll_current_delay = self._poll_base_delay

        def poll_once():
            def on_success(response_data):
                # メッセージ表示
                self._display_messages_list(response_data)
                # 成功時は 5s に戻す
                self._poll_current_delay = self._poll_base_delay
                self._poll_after_id = self.after(self._poll_current_delay, poll_once)

            def on_error(_e):
                # 失敗時は指数バックオフ（最大60s）
                self._poll_current_delay = min(
                    self._poll_max_delay, max(5000, self._poll_current_delay * 2)
                )
                self._poll_after_id = self.after(self._poll_current_delay, poll_once)

            # 実処理は BG で
            self.run_bg(self._fetch_messages_work, on_success, on_error)

        # すぐに 1 回実行
        poll_once()

    def stop_polling(self):
        if self._poll_after_id is not None:
            try:
                self.after_cancel(self._poll_after_id)
            except Exception:
                pass
            self._poll_after_id = None

    def focus_next_widget(self, event):
        widget = event.widget.tk_focusNext()
        if widget:
            widget.focus_set()
        return "break"

    def focus_prev_widget(self, event):
        widget = event.widget.tk_focusPrev()
        if widget:
            widget.focus_set()
        return "break"

    def on_readonly_text_keypress(self, event):
        # 読み取り専用風：タブ移動・コピーなどは許可
        if event.keysym in ("Tab", "ISO_Left_Tab"):
            return
        if (event.state & 0x4) and event.keysym.lower() in ("a", "c", "insert"):
            return
        if event.char == "":
            return
        return "break"

    def display_message(self, message, role=None):
        # BGスレッドからも呼ばれる可能性があるため UI 反映をスケジューリング
        def _do():
            text_value = str(message)
            blocked_phrases = (
                "[\u30b7\u30b9\u30c6\u30e0] \u30ed\u30b0\u30a4\u30f3\u3057\u307e\u3057\u305f\u3002",
                "[\u30b7\u30b9\u30c6\u30e0] \u30ed\u30b0\u30a2\u30a6\u30c8\u3057\u307e\u3057\u305f\u3002",
                "\u30b7\u30b9\u30c6\u30e0] \u30ed\u30b0\u30a4\u30f3\u3057\u307e\u3057\u305f\u3002",
                "\u30b7\u30b9\u30c6\u30e0] \u30ed\u30b0\u30a2\u30a6\u30c8\u3057\u307e\u3057\u305f\u3002",
            )
            if (
                any(phrase in text_value for phrase in blocked_phrases)
                or "[システム]" in text_value
            ):
                return
            self._append_chat_line(text_value, role)

        if threading.current_thread() is threading.main_thread():
            _do()
        else:
            try:
                self.after(0, _do)
            except Exception:
                pass

    def _append_chat_line(self, message, role=None):
        widget = getattr(self, "chat_display", None)
        if widget is None:
            return

        tag = None
        if role == "admin":
            tag = "chat_admin"
        elif role == "user":
            tag = "chat_user"

        if not tag:
            tag = "chat_default"

        text_value = message.rstrip("\n") + "\n"
        # 末尾追記 + 末尾へスクロール
        widget.insert("end", text_value, tag)
        widget.see("end")

    def notice_clear(self):
        try:
            self.workspace.config(state="normal")
            self.workspace.delete("1.0", tk.END)
        except Exception:
            pass

    # ------- 定期送信（中間/キーボード/マウス） -------
    def tick_middle(self):
        if not self.logged_in or not getattr(self, "training_started", False) or not self.token:
            return
        millis = max(1, int(self.middle_interval_minutes)) * 60 * 1000

        def work():
            url = self.base_url.rstrip("/") + "/training_middle.php"
            cap = self.capture_screen_file()
            files = {"file": cap} if cap else None
            try:
                self.post_multipart(url, {"value": self.token}, files)
            except Exception:
                logging.warning("middle tick post failed", exc_info=True)

        self.run_bg(work)
        self._middle_after_id = self.after(millis, self.tick_middle)

    def start_middle_timer(self):
        self.stop_middle_timer()
        self.tick_middle()

    def stop_middle_timer(self):
        if self._middle_after_id is not None:
            try:
                self.after_cancel(self._middle_after_id)
            except Exception:
                pass
            self._middle_after_id = None

    def start_kbd_mouse_listeners(self):
        try:
            from pynput import keyboard, mouse
        except Exception as e:
            self.display_message(f"[注意] 入力監視未対応: pynput をインストールしてください ({e})")
            return

        def on_key_press(key):
            self.keyboard_flag = "1"

        def on_key_release(key):
            self.keyboard_flag = "1"

        def on_move(x, y):
            self.mouse_flag = "1"

        def on_click(x, y, button, pressed):
            self.mouse_flag = "1"

        def on_scroll(x, y, dx, dy):
            self.mouse_flag = "1"

        try:
            self._kbd_listener = keyboard.Listener(on_press=on_key_press, on_release=on_key_release)
            self._mouse_listener = mouse.Listener(
                on_move=on_move, on_click=on_click, on_scroll=on_scroll
            )
            self._kbd_listener.daemon = True
            self._mouse_listener.daemon = True
            self._kbd_listener.start()
            self._mouse_listener.start()
        except Exception as e:
            self.display_message(f"[注意] 入力監視開始に失敗: {e}")

    def stop_kbd_mouse_listeners(self):
        try:
            if self._kbd_listener is not None:
                self._kbd_listener.stop()
        except Exception:
            pass
        try:
            if self._mouse_listener is not None:
                self._mouse_listener.stop()
        except Exception:
            pass
        self._kbd_listener = None
        self._mouse_listener = None

    def tick_keyboard(self):
        if not self.logged_in or not getattr(self, "training_started", False) or not self.token:
            return
        millis = max(1, int(self.keyboard_interval_minutes)) * 60 * 1000
        flag = self.keyboard_flag

        def work():
            url = self.base_url.rstrip("/") + "/training_keyboard.php"
            try:
                self.post_multipart(url, {"value": self.token, "value2": flag})
            except Exception:
                logging.warning("keyboard tick post failed", exc_info=True)

        def done(_=None):
            self.keyboard_flag = "2"

        self.run_bg(work, done, lambda _e: done())
        self._kbd_after_id = self.after(millis, self.tick_keyboard)

    def start_keyboard_timer(self):
        self.stop_keyboard_timer()
        self.tick_keyboard()

    def stop_keyboard_timer(self):
        if self._kbd_after_id is not None:
            try:
                self.after_cancel(self._kbd_after_id)
            except Exception:
                pass
            self._kbd_after_id = None

    def tick_mouse(self):
        if not self.logged_in or not getattr(self, "training_started", False) or not self.token:
            return
        millis = max(1, int(self.mouse_interval_minutes)) * 60 * 1000
        flag = self.mouse_flag

        def work():
            url = self.base_url.rstrip("/") + "/training_mouse.php"
            try:
                self.post_multipart(url, {"value": self.token, "value2": flag})
            except Exception:
                logging.warning("mouse tick post failed", exc_info=True)

        def done(_=None):
            self.mouse_flag = "2"

        self.run_bg(work, done, lambda _e: done())
        self._mouse_after_id = self.after(millis, self.tick_mouse)

    def start_mouse_timer(self):
        self.stop_mouse_timer()
        self.tick_mouse()

    def stop_mouse_timer(self):
        if self._mouse_after_id is not None:
            try:
                self.after_cancel(self._mouse_after_id)
            except Exception:
                pass
            self._mouse_after_id = None

    # 画像の表示
    def display_captured_image(self, frame):
        if not isinstance(getattr(self, "capture_display_label", None), tk.Label):
            self.display_message("[注意] 画像エリアが未初期化のため表示をスキップしました。")
            return
        try:
            import cv2

            img_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
            pil_img = Image.fromarray(img_rgb).resize(
                (self.IMG_W, self.IMG_H), Image.Resampling.LANCZOS
            )
            self.last_capture_photo = ImageTk.PhotoImage(pil_img)
            self.capture_display_label.config(image=self.last_capture_photo, bg="white")
            self.capture_display_label.image = self.last_capture_photo  # 参照保持
        except Exception as e:
            self.display_message(f"[表示エラー] {e}")

    def clear_image(self):
        if isinstance(getattr(self, "capture_display_label", None), tk.Label):
            self.capture_display_label.config(image="", bg="white")
            self.capture_display_label.image = None
            self.last_capture_photo = None

    # 画面キャプチャ（可能ならImageGrab→mssの順で試す）
    def capture_screen_file(self):
        # まず PIL.ImageGrab
        try:
            from PIL import ImageGrab

            img = ImageGrab.grab()
            img = img.resize((320, 240))
            bio = io.BytesIO()
            img.save(bio, format="JPEG", quality=80, optimize=True)
            bio.seek(0)
            return ("capture.jpg", bio, "image/jpeg")
        except Exception as e_grab:
            # 次に mss
            try:
                import mss

                with mss.mss() as sct:
                    monitor = sct.monitors[0]  # 全画面
                    shot = sct.grab(monitor)
                    from PIL import Image as PILImage2

                    im = PILImage2.frombytes("RGB", shot.size, shot.rgb)
                    im = im.resize((320, 240))
                    bio = io.BytesIO()
                    im.save(bio, format="JPEG", quality=80, optimize=True)
                    bio.seek(0)
                    return ("capture.jpg", bio, "image/jpeg")
            except Exception as e_mss:
                self.display_message(
                    f"[注意] 画面キャプチャ未対応: PIL.ImageGrab または mss をインストール/許可してください ({e_grab} / {e_mss})"
                )
                return None

    def on_close(self):
        if self.logged_in:
            if not messagebox.askokcancel("確認", "ログアウトせずに終了しますか？"):
                return
        try:
            self.stop_polling()
            self.stop_middle_timer()
            self.stop_keyboard_timer()
            self.stop_mouse_timer()
            self.stop_kbd_mouse_listeners()
        finally:
            self.destroy()


if __name__ == "__main__":
    app = MainWindow()
    app.mainloop()
