# -*- coding: utf-8 -*-
# 年年识字 — 给孩子记生字和单词的学习程序
# 【平台兼容】Windows + Linux Mint，Python 3.12+，只用标准库
#
# =============================================================================
# 【给其它机器人 / Agent】
# -----------------------------------------------------------------------------
# 这是学习软件，不是看图脚本。目标：让孩子反复见到生字和单词，听读音，再自己判断记没记住。
#
# 必须保持：
# 1. 有 GUI（tkinter）。字画在本程序窗口里。
#    禁止 PIL.Image.show、禁止保存 0.png、禁止调用系统看图、
#    禁止 taskkill 看图进程、禁止 pyautogui 切窗口。
# 2. Windows 和 Linux Mint 都要能用。平台只看 sys.platform == 'win32'。
#    非 Windows 一律按 Linux Mint 处理（与 yh_platform.is_mint 相同）。
# 3. 不要 import yh_os、yh_save、yh_ocr、PIL、pyautogui、playsound、baidu-aip。
#    这些在 Python 3.12 上缺依赖，一导入程序就起不来。
# 4. 朗读只走本文件 speak()：
#    Windows：PowerShell System.Speech + SSML。
#    英文词用 Microsoft Zira Desktop（或本机其它 en 语音），中文用 Huihui。
#    语速略慢，词前后加短停顿，避免单字/单词过短、不自然。
#    Mint：中文优先用 nn_shengzi_voices 里的 MeloTTS（vits-melo-tts-zh_en）。
#    它自带词库和多音字规则，汉字按词读，不走 espeak。
#    单个汉字不要交给这套语音：它会把「天」「把」等合成成空白，程序就会当成已经读过。
#    单字仍用 Piper zh_CN-huayan。两个字及以上才用 MeloTTS，并把音量拉到峰值约 24000。
#    模型由 sherpa/nn-zh-tts 加载一次后常驻。噪声和线程都设成最稳的一档。
#    英文仍用 Piper en_US-lessac（美式英语）。调用时把 LD_LIBRARY_PATH 指到 piper/。
#    MeloTTS 文件不齐时，中文才退回 Piper zh_CN-huayan：
#    两个字及以上先查 cedict_ts.u8（外加 _EXTRA_PINYIN），再按拼音去读。
#    不要改回系统 espeak 的 cmn。那套词典把汉字按英语拼出来：
#    「天」会读成 tian one，「好」会读成 hao three。
#    两套自然语音都不在时才退回 spd-say：中文语言码 cmn，英文 en-US。
#    中文语速和英文语速分开，设置项是 speak_rate_zh、speak_rate_en。
#    旧设置里只有 speak_rate 时，两种语言都沿用那个值。
#    设置「单词显示音标」打开后，「单词」页在英文下面写音标。音标来自美式英语音素。
#    本机没有朗读命令时只显示，不要因此退出。
# 5. 词库在 builtin_decks()。没有设置文件时默认进「生字」，生字来源默认是「软件自带」。
#    不要改回「实际只练字母」。字母只是其中一个练习页。
#    生字 = 当前勾选的来源里的中文。数字 = 0 到 9 和一到十；字母 = 大小写字母。
#    数字和字母是幼儿识字，一直都在，课本不代替它们。
#    单词只认来源里的英文。勾了「简单汉字」才带上软件自带的几个英文单词。
#    只勾中文课本时，单词这一页是空的，显示「还没有内容」。
#    主窗口「生字来源」点开后是更大的多选窗口：对号「✓」表示已选，空方框「□」表示未选。
#    固定有「简单汉字」「自定义文本」，其余来自
#    https://www.mkcmd.com/download/niannian/index.json 。
#    课表缓存在脚本目录 nn_shengzi_books/。勾选后下载对应 txt，一行一条。
#    以 # 开头的行是说明，不拿来认。听完不要自动跳下一题。
#    英文行可写成「hello|你好」或「hello<TAB>你好」，竖线/制表符后是中文释义。
#    「单词」页有「翻译」按钮，打开后显示预置释义（不联网查词典）。
#    本轮记住了多少只放内存，不要写成绩文件。book_ids 只记勾选了哪些来源。
# 6. 学习规则：每一页一轮一个队列。
#    先看设置里的秒数（默认 10 秒，可按空格或「朗读」提前听）→ 听完停在这一题 →
#    等孩子点「记住了」（移出本轮）或「下一个词」（当前词排到队尾，先练后面的）。
#    看完是否自动朗读由设置决定。听完不要自动跳下一题。
#    本轮记住了多少只放内存，不要写成绩文件。
#    不要往控制台 print（Windows 双击/带控制台运行时会多出一个 DOS 黑窗刷字）。
# 7. 主窗口有「设置」，三页：
#    常规：看字秒数、启动时进入哪一页。
#    语音：看完自动朗读、中文语速、英文语速、词前后停顿、中文/英文音色、试听。
#    自定义文本：编辑内容，并可指定文本文件位置（手填路径或浏览）。
#    一行一条。整份只有一行且没有逗号时，按单字拆开。
#    配置写在脚本同目录 nn_shengzi_settings.json（utf-8）。
#    正文写在 text_path 指向的文件，用 utf-8 保存，不要写进 json。
#    读取时先按 utf-8，失败再按 gb18030，旧的 gb18030 文本还能打开。
#    还没有设置文件时，工作目录或脚本目录的 read.txt 仍当作自定义文本；
#    找到了就默认打开「自定义」。保存时若自定义文本里有内容，马上进入这一页。
# 8. 字体按本机已安装的挑。Windows 优先黑体、微软雅黑；
#    Mint 优先 Noto Sans CJK SC、文泉驿微米黑。不要写死单一字体文件路径。
# 9. 启动时先读 https://www.mkcmd.com/download/niannian/version.txt
#    两行：version=2026.11.10.1 和 url=https://www.mkcmd.com/download/niannian/nn_shengzi.py
#    服务器版本号更大时，下载这个 py，核对里面的 __version__ 与之一致，再覆盖本文件并重新启动。
#    版本不高于本机、地址不是这个文件、内容对不上、或网络失败，都继续用当前这份。
#    不要因此弹窗问要不要更新，也不要 print。
#    设置和课表缓存留在本机，不要跟着程序文件一起换。
#    发新版时加大 __version__，并把 nn_shengzi.py 与 version.txt 放到上述下载目录。
#    语言包是同目录的 nn_shengzi_voices.tar.gz，解压后得到 nn_shengzi_voices/。
#    程序不自动下载语言包。本机版本不低于服务器时不覆盖。
#
# 运行目录：Windows 为 f:\netfiles\python，Mint 为 /d/netfiles/python
#   py -3.12 nn_shengzi.py
#   python3 nn_shengzi.py
# 改完用 Python 3.12 做短测试：能导入、词库非空、窗口能建起来。
# 不要为了测试循环朗读，也不要真的连续喊一长串语音。
# =============================================================================
import json
import os
import queue
import random
import array
import atexit
import select
import wave
import shutil
import subprocess
import sys
import base64
import tempfile
import threading
import urllib.error
import urllib.parse
import urllib.request
import tkinter as tk
import tkinter.font as tkfont
from tkinter import filedialog, messagebox

CREATE_NO_WINDOW = 0x08000000
IS_WIN = sys.platform == 'win32'
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
SETTINGS_PATH = os.path.join(SCRIPT_DIR, 'nn_shengzi_settings.json')
CACHE_DIR = os.path.join(SCRIPT_DIR, 'nn_shengzi_books')
CATALOG_URL = 'https://www.mkcmd.com/download/niannian/index.json'
BOOK_BASE = 'https://www.mkcmd.com/download/niannian/'
__version__ = '2026.11.10.1'
VERSION_URL = 'https://www.mkcmd.com/download/niannian/version.txt'
PROGRAM_URL = 'https://www.mkcmd.com/download/niannian/nn_shengzi.py'
VOICE_DIR = os.path.join(SCRIPT_DIR, 'nn_shengzi_voices')
PIPER_BIN = os.path.join(VOICE_DIR, 'piper', 'piper')
NEURAL_ZH = '普通话'
NEURAL_EN = '美式英语'
NEURAL_FILES = {
	NEURAL_ZH: 'zh_CN-huayan-medium.onnx',
	NEURAL_EN: 'en_US-lessac-medium.onnx',
}
DECK_NAMES = ('生字', '数字', '字母', '单词', '词语', '自定义')
BUILTIN_CHARS = '连元丁厂目见兔是把笑着看会自己画好爱心也可家这斧爷奶们书串球晶圆球形长方正角灯块'
BUILTIN_ENGLISH = 'dog|狗,ok|好的,good|好的,cat|猫,nice|好的,hello|你好,apple|苹果,banana|香蕉,blue|蓝色的'
SOURCE_BUILTIN = 'builtin'
SOURCE_CUSTOM = 'custom'
VOICE_AUTO = '自动'
SPEAK_PAUSE_CHOICES = ('短', '中', '长')
# 停顿：(词前 ms, 词后 ms)；短词会再略加长一点
SPEAK_PAUSE_MS = {
	'短': (100, 180),
	'中': (280, 420),
	'长': (450, 700),
}
DEFAULT_SETTINGS = {
	'look_seconds': 10,
	'auto_speak': True,
	'start_deck': '生字',
	'text_path': '',
	'book_ids': [SOURCE_BUILTIN],
	'speak_rate_zh': -3,  # -5 很慢 … 0 正常 … 5 很快
	'speak_rate_en': -3,
	'speak_pause': '中',
	'voice_zh': VOICE_AUTO,
	'voice_en': VOICE_AUTO,
	'show_ipa': False,
}
_VOICE_CACHE = None

FONT_CANDIDATES = (
	'黑体',
	'SimHei',
	'微软雅黑',
	'Microsoft YaHei',
	'Noto Sans CJK SC',
	'Noto Sans CJK TC',
	'Noto Sans CJK JP',
	'Source Han Sans SC',
	'Source Han Sans CN',
	'WenQuanYi Micro Hei',
	'文泉驿微米黑',
	'WenQuanYi Zen Hei',
	'文泉驿正黑',
	'Droid Sans Fallback',
)


def read_text(path):
	# utf-8 优先。gb18030 很宽容，先用它会把 utf-8 中文读成乱码。
	for enc in ('utf-8', 'gb18030'):
		try:
			with open(path, 'r', encoding=enc) as f:
				return f.read()
		except UnicodeError:
			continue
	return ''


def parse_entry(raw):
	"""一行 → (练习用词, 中文释义)。支持 word|释义 或 word<TAB>释义。"""
	raw = (raw or '').strip()
	if not raw or raw.startswith('#'):
		return None
	for sep in ('|', '\t'):
		if sep in raw:
			left, right = raw.split(sep, 1)
			left = left.strip()
			right = right.strip()
			if left:
				return left, right
			return None
	return raw, ''


def comma_items(text):
	text = text.replace('，', ',')
	items = []
	seen = set()
	for part in text.split(','):
		parsed = parse_entry(part)
		if not parsed:
			continue
		word = parsed[0]
		if word and word not in seen:
			seen.add(word)
			items.append(word)
	return items


def comma_entries(text):
	"""逗号分隔条目，保留释义。"""
	text = text.replace('，', ',')
	entries = []
	seen = set()
	for part in text.split(','):
		parsed = parse_entry(part)
		if not parsed:
			continue
		word, gloss = parsed
		if word and word not in seen:
			seen.add(word)
			entries.append((word, gloss))
	return entries


def char_items(text):
	text = text.replace('，', '').replace(',', '').replace(' ', '')
	items = []
	seen = set()
	for ch in text:
		if ch and ch not in seen:
			seen.add(ch)
			items.append(ch)
	return items


def custom_items(text):
	lines = [line.strip() for line in text.splitlines() if line.strip() and not line.strip().startswith('#')]
	if not lines:
		return []
	if len(lines) == 1 and not any(sep in lines[0] for sep in (',', '，', '|', '\t')):
		return char_items(lines[0])
	items = []
	seen = set()
	for line in lines:
		if ',' in line or '，' in line:
			parts = comma_items(line)
		else:
			parsed = parse_entry(line)
			parts = [parsed[0]] if parsed else []
		for part in parts:
			if part not in seen:
				seen.add(part)
				items.append(part)
	return items


def norm_path(path):
	path = (path or '').strip()
	if not path:
		return ''
	return os.path.abspath(os.path.expanduser(path))


def find_legacy_read_txt():
	for folder in (os.getcwd(), SCRIPT_DIR):
		path = os.path.join(folder, 'read.txt')
		if os.path.isfile(path):
			return os.path.abspath(path)
	return ''


def load_settings(path=None):
	path = path or SETTINGS_PATH
	data = dict(DEFAULT_SETTINGS)
	found_file = os.path.isfile(path)
	saved = {}
	if found_file:
		try:
			with open(path, 'r', encoding='utf-8') as f:
				loaded = json.load(f)
			if isinstance(loaded, dict):
				saved = loaded
				for key in DEFAULT_SETTINGS:
					if key in saved:
						data[key] = saved[key]
		except (OSError, UnicodeError, json.JSONDecodeError):
			pass
	data['look_seconds'] = _look_seconds(data.get('look_seconds'))
	data['auto_speak'] = bool(data.get('auto_speak', True))
	start = str(data.get('start_deck') or '生字')
	data['start_deck'] = start if start in DECK_NAMES else '生字'
	data['text_path'] = norm_path(str(data.get('text_path') or ''))
	raw_ids = data.get('book_ids')
	if isinstance(raw_ids, list):
		data['book_ids'] = [str(item).strip() for item in raw_ids if str(item).strip()]
	else:
		data['book_ids'] = [SOURCE_BUILTIN]
	legacy_rate = _speak_rate(saved.get('speak_rate', data.get('speak_rate_zh')))
	if 'speak_rate_zh' not in saved:
		data['speak_rate_zh'] = legacy_rate
	if 'speak_rate_en' not in saved:
		data['speak_rate_en'] = legacy_rate
	data['speak_rate_zh'] = _speak_rate(data.get('speak_rate_zh'))
	data['speak_rate_en'] = _speak_rate(data.get('speak_rate_en'))
	data['speak_pause'] = _speak_pause(data.get('speak_pause'))
	data['voice_zh'] = _voice_name(data.get('voice_zh'))
	data['voice_en'] = _voice_name(data.get('voice_en'))
	data['show_ipa'] = bool(data.get('show_ipa', False))
	if not found_file and not data['text_path']:
		legacy = find_legacy_read_txt()
		if legacy:
			data['text_path'] = legacy
			data['start_deck'] = '自定义'
	return data


def _look_seconds(value):
	try:
		seconds = int(value)
	except (TypeError, ValueError):
		return 10
	if seconds < 1:
		return 1
	if seconds > 120:
		return 120
	return seconds


def _rate_of(settings, english):
	"""中文、英文各用各的语速。旧配置只有一个 speak_rate 时两种都用它。"""
	settings = settings or DEFAULT_SETTINGS
	key = 'speak_rate_en' if english else 'speak_rate_zh'
	if key in settings and settings.get(key) is not None:
		return _speak_rate(settings.get(key))
	return _speak_rate(settings.get('speak_rate'))


def _speech_length(settings, english):
	"""Piper 的 length_scale。大于 1 更慢。"""
	rate = _rate_of(settings, english)
	return max(0.75, min(1.35, 1.0 - rate * 0.06))


def _speak_rate(value):
	try:
		rate = int(value)
	except (TypeError, ValueError):
		return -3
	if rate < -5:
		return -5
	if rate > 5:
		return 5
	return rate


def _speak_pause(value):
	text = str(value or '').strip()
	if text in SPEAK_PAUSE_MS:
		return text
	return '中'


def _voice_name(value):
	text = str(value or '').strip()
	return text or VOICE_AUTO


def save_settings(data, path=None):
	path = path or SETTINGS_PATH
	out = {
		'look_seconds': _look_seconds(data.get('look_seconds')),
		'auto_speak': bool(data.get('auto_speak', True)),
		'start_deck': data.get('start_deck') if data.get('start_deck') in DECK_NAMES else '生字',
		'text_path': norm_path(str(data.get('text_path') or '')),
		'book_ids': _book_ids(data.get('book_ids')),
		'speak_rate_zh': _speak_rate(data.get('speak_rate_zh')),
		'speak_rate_en': _speak_rate(data.get('speak_rate_en')),
		'speak_pause': _speak_pause(data.get('speak_pause')),
		'voice_zh': _voice_name(data.get('voice_zh')),
		'voice_en': _voice_name(data.get('voice_en')),
		'show_ipa': bool(data.get('show_ipa', False)),
	}
	folder = os.path.dirname(path)
	if folder and not os.path.isdir(folder):
		raise OSError('文件夹不存在：%s' % folder)
	with open(path, 'w', encoding='utf-8', newline='\n') as f:
		json.dump(out, f, ensure_ascii=False, indent=2)
		f.write('\n')
	return out


def write_custom_text(path, content):
	path = norm_path(path)
	if not path:
		raise OSError('还没有指定文本位置')
	folder = os.path.dirname(path)
	if folder and not os.path.isdir(folder):
		raise OSError('文件夹不存在：%s' % folder)
	if content and not content.endswith('\n'):
		content += '\n'
	with open(path, 'w', encoding='utf-8', newline='\n') as f:
		f.write(content)


def _book_ids(value):
	if not isinstance(value, list):
		return [SOURCE_BUILTIN]
	ids = []
	seen = set()
	for item in value:
		item = str(item).strip()
		if item and item not in seen:
			seen.add(item)
			ids.append(item)
	return ids


def book_entries(text):
	"""课本/缓存文本 → [(词, 释义), ...]。"""
	entries = []
	seen = set()
	for line in text.splitlines():
		parsed = parse_entry(line)
		if not parsed:
			continue
		word, gloss = parsed
		if word in seen:
			continue
		seen.add(word)
		entries.append((word, gloss))
	return entries


def book_items(text):
	return [word for word, _gloss in book_entries(text)]


def catalog_cache_path():
	return os.path.join(CACHE_DIR, 'index.json')


def book_cache_path(book_id):
	safe = ''.join(ch for ch in str(book_id) if ch.isalnum() or ch in '-_')
	if not safe:
		safe = 'book'
	return os.path.join(CACHE_DIR, safe + '.txt')


def load_catalog():
	path = catalog_cache_path()
	if not os.path.isfile(path):
		return []
	try:
		with open(path, 'r', encoding='utf-8') as f:
			data = json.load(f)
	except (OSError, UnicodeError, json.JSONDecodeError):
		return []
	return _catalog_items(data)


def _catalog_items(data):
	rows = data.get('items') if isinstance(data, dict) else None
	if not isinstance(rows, list):
		return []
	items = []
	seen = set()
	for row in rows:
		if not isinstance(row, dict):
			continue
		book_id = str(row.get('id') or '').strip()
		title = str(row.get('title') or '').strip()
		filename = os.path.basename(str(row.get('file') or '').replace('\\', '/'))
		if not book_id or not title or not filename or book_id in seen:
			continue
		if book_id in (SOURCE_BUILTIN, SOURCE_CUSTOM):
			continue
		seen.add(book_id)
		items.append({'id': book_id, 'title': title, 'file': filename})
	return items


def _http_bytes(url, timeout=20, limit=2000000, allow=None):
	req = urllib.request.Request(url, headers={'User-Agent': 'nn-shengzi'})
	with urllib.request.urlopen(req, timeout=timeout) as resp:
		if allow is not None and not allow(resp.geturl()):
			raise OSError('地址不对')
		data = resp.read(limit + 1)
	if len(data) > limit:
		raise OSError('文件太大')
	return data


def _version_tuple(text):
	parts = []
	for piece in str(text or '').strip().split('.'):
		if not piece.isdigit():
			return ()
		parts.append(int(piece))
	return tuple(parts)


def _allowed_program_url(url):
	parts = urllib.parse.urlsplit(str(url or '').strip())
	if parts.scheme != 'https' or parts.netloc != 'www.mkcmd.com':
		return False
	if parts.query or parts.fragment:
		return False
	pieces = parts.path.split('/')
	if '..' in pieces:
		return False
	return parts.path == '/download/niannian/nn_shengzi.py'


def _allowed_version_url(url):
	parts = urllib.parse.urlsplit(str(url or '').strip())
	if parts.scheme != 'https' or parts.netloc != 'www.mkcmd.com':
		return False
	if parts.query or parts.fragment:
		return False
	return parts.path == '/download/niannian/version.txt'


def _read_remote_release():
	text = _http_bytes(VERSION_URL, timeout=4, limit=4000, allow=_allowed_version_url).decode('utf-8')
	ver = ''
	url = ''
	for line in text.splitlines():
		line = line.strip()
		if not line or line.startswith('#'):
			continue
		if line.startswith('version='):
			ver = line.split('=', 1)[1].strip()
		elif line.startswith('url='):
			url = line.split('=', 1)[1].strip()
	if not _version_tuple(ver) or not _allowed_program_url(url):
		raise ValueError('版本信息不对')
	return ver, url


def _program_matches(text, remote_ver):
	marker = "__version__ = '%s'" % remote_ver
	return '年年识字' in text and marker in text


def _close_notice(notice):
	if notice is None:
		return
	try:
		notice.destroy()
	except tk.TclError:
		pass


def _update_notice():
	root = tk.Tk()
	root.title('年年识字')
	root.resizable(False, False)
	tk.Label(root, text='正在换成新版本', padx=28, pady=18, font=('', 16)).pack()
	root.update_idletasks()
	width, height = 280, 90
	x = max((root.winfo_screenwidth() - width) // 2, 0)
	y = max((root.winfo_screenheight() - height) // 2, 0)
	root.geometry('%sx%s+%s+%s' % (width, height, x, y))
	root.update()
	return root


def maybe_update():
	"""服务器版本更高时，换成那份程序并重新启动。失败则继续用当前这份。"""
	if os.environ.get('NN_SHENGZI_UPDATED') == '1':
		return
	if getattr(sys, 'frozen', False):
		return
	try:
		remote_ver, url = _read_remote_release()
	except (OSError, UnicodeError, urllib.error.URLError, ValueError, TimeoutError):
		return
	if _version_tuple(remote_ver) <= _version_tuple(__version__):
		return
	notice = None
	try:
		notice = _update_notice()
		raw = _http_bytes(url, timeout=20, allow=_allowed_program_url)
		text = raw.decode('utf-8')
	except (OSError, UnicodeError, urllib.error.URLError, ValueError, TimeoutError, tk.TclError):
		_close_notice(notice)
		return
	if not _program_matches(text, remote_ver):
		_close_notice(notice)
		return
	target = os.path.abspath(__file__)
	tmp = target + '.new'
	try:
		with open(tmp, 'wb') as handle:
			handle.write(raw)
		os.replace(tmp, target)
	except OSError:
		try:
			os.remove(tmp)
		except OSError:
			pass
		_close_notice(notice)
		return
	_close_notice(notice)
	os.environ['NN_SHENGZI_UPDATED'] = '1'
	os.execv(sys.executable, [sys.executable, target] + sys.argv[1:])


def fetch_catalog():
	data = json.loads(_http_bytes(CATALOG_URL).decode('utf-8'))
	items = _catalog_items(data)
	os.makedirs(CACHE_DIR, exist_ok=True)
	with open(catalog_cache_path(), 'w', encoding='utf-8', newline='\n') as f:
		json.dump({'items': items}, f, ensure_ascii=False, indent=2)
		f.write('\n')
	return items


def download_book(row):
	filename = os.path.basename(str(row.get('file') or ''))
	if not filename:
		raise OSError('文件名不对')
	url = BOOK_BASE + urllib.parse.quote(filename)
	raw = _http_bytes(url)
	text = None
	for enc in ('utf-8', 'gb18030'):
		try:
			text = raw.decode(enc)
			break
		except UnicodeError:
			continue
	if text is None:
		raise UnicodeError('编码不对')
	os.makedirs(CACHE_DIR, exist_ok=True)
	with open(book_cache_path(row['id']), 'w', encoding='utf-8', newline='\n') as f:
		f.write(text)


def download_selected(items, selected):
	wanted = set(selected or [])
	missed = []
	for row in items:
		if row.get('id') not in wanted:
			continue
		try:
			download_book(row)
		except (OSError, UnicodeError, urllib.error.URLError, json.JSONDecodeError):
			missed.append(row.get('title') or row.get('id'))
	return missed


def is_english_item(item):
	# 含拉丁字母、又不含汉字，就算英文。中文课本里的词不会进「单词」。
	latin = False
	for ch in item or '':
		if '\u4e00' <= ch <= '\u9fff':
			return False
		if ch.isascii() and ch.isalpha():
			latin = True
	return latin


def _add_gloss(glosses, word, gloss):
	if word and gloss and word not in glosses:
		glosses[word] = gloss


def source_entries(settings):
	"""按来源收集条目，并汇总英文释义。返回 (全部词条列表, 英文→中文)。"""
	selected = _book_ids(settings.get('book_ids'))
	items = []
	glosses = {}
	seen = set()

	def add_word(word, gloss=''):
		if not word or word in seen:
			if gloss:
				_add_gloss(glosses, word, gloss)
			return
		seen.add(word)
		items.append(word)
		_add_gloss(glosses, word, gloss)

	if SOURCE_BUILTIN in selected:
		for ch in char_items(BUILTIN_CHARS):
			add_word(ch)
		for word, gloss in comma_entries(BUILTIN_ENGLISH):
			add_word(word, gloss)
	if SOURCE_CUSTOM in selected:
		path = norm_path(settings.get('text_path'))
		if path and os.path.isfile(path):
			text = read_text(path)
			raw_lines = [ln.strip() for ln in text.splitlines() if ln.strip() and not ln.strip().startswith('#')]
			if len(raw_lines) == 1 and not any(sep in raw_lines[0] for sep in (',', '，', '|', '\t')):
				for ch in char_items(raw_lines[0]):
					add_word(ch)
			else:
				for word, gloss in book_entries(text):
					add_word(word, gloss)
	by_id = {row['id']: row for row in load_catalog()}
	for book_id in selected:
		row = by_id.get(book_id)
		if not row:
			continue
		path = book_cache_path(book_id)
		if os.path.isfile(path):
			for word, gloss in book_entries(read_text(path)):
				add_word(word, gloss)
	return items, glosses


def source_items(settings):
	items, _glosses = source_entries(settings)
	return items


def builtin_decks(shengzi=None, english=None):
	letters = 'a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,r,u,v,w,x,y,z,ABCDEFG,HIJKLMN,OPQ,RST,UVW,XYZ'
	numbers = '0123456789一二三四五六七八九十'
	chars = BUILTIN_CHARS
	words = ',你好，原来，故事，过年，来了，成长，金鱼，老牛，平分，连连看,金色，木头，玄鸟，耳朵，黄瓜，手足，川流不息，种田，禾苗，云朵，雨天，甲虫，力气，男生'
	words += ',心里，格尺，本色，大刀，风雨，立正，保卫，好的，可是，树叶，东西，马车，有用，小鸟，可是，开关，先后，里面，看见，左右，你我他，我和你，也是，才是，又来了'
	words += ',爸妈，比较，下巴，长短，公母，只有，石头，出来，半夜，牛羊，水果，白色'
	words += ',下雨，开车，山羊，只有，牛马，大风，一头牛，田地，土里，在这里，白马王子，出去，大刀，木头，飞鸟，雨伞，金鱼，西瓜，生气，飞鸟，虫子，大米，云朵，电灯，石头，'
	words += ',美玉，回去，问题，早安，干活，几个，男生，坐下来，闪电，花朵，好的，比较，牛马，午饭，西面，高山，包子，语文，森林'
	if shengzi is None:
		shengzi = char_items(chars)
	if english is None:
		english = comma_items(BUILTIN_ENGLISH)
	return [
		('生字', shengzi),
		('数字', char_items(numbers)),
		('字母', char_items(letters)),
		('单词', list(english)),
		('词语', comma_items(words)),
	]


def load_decks(settings):
	decks = []
	glosses = {}
	path = norm_path(settings.get('text_path'))
	if path and os.path.isfile(path):
		text = read_text(path)
		raw_lines = [ln.strip() for ln in text.splitlines() if ln.strip() and not ln.strip().startswith('#')]
		if len(raw_lines) == 1 and not any(sep in raw_lines[0] for sep in (',', '，', '|', '\t')):
			custom = char_items(raw_lines[0])
		else:
			custom = []
			seen = set()
			for line in text.splitlines():
				parsed = parse_entry(line)
				if not parsed:
					continue
				word, gloss = parsed
				if word in seen:
					continue
				seen.add(word)
				custom.append(word)
				_add_gloss(glosses, word, gloss)
		if custom:
			decks.append(('自定义', custom))
	all_items, src_gloss = source_entries(settings)
	for k, v in src_gloss.items():
		_add_gloss(glosses, k, v)
	chinese = []
	english = []
	seen_english = set()
	for item in all_items:
		if is_english_item(item):
			if item not in seen_english:
				seen_english.add(item)
				english.append(item)
		else:
			chinese.append(item)
	decks.extend(builtin_decks(chinese, english))
	return decks, glosses


def initial_mode(decks, settings):
	names = [name for name, _items in decks]
	want = settings.get('start_deck') or '生字'
	if want in names:
		return want
	if '生字' in names:
		return '生字'
	return names[0]


def pick_font_family(root):
	families = list(tkfont.families(root))
	have = set(families)
	for name in FONT_CANDIDATES:
		if name in have:
			return name
	for name in families:
		low = name.lower()
		if 'cjk' in low or 'wqy' in low or 'source han' in low or 'hei' in low or '黑' in name:
			return name
	return 'TkDefaultFont'


def fit_font_size(family, text, width, height):
	width = max(width, 200)
	height = max(height, 160)
	probe = tkfont.Font(family=family, size=40)
	lo, hi = 24, 360
	best = 24
	while lo <= hi:
		mid = (lo + hi) // 2
		probe.configure(size=mid)
		if probe.measure(text) <= width - 80 and probe.metrics('linespace') <= height - 40:
			best = mid
			lo = mid + 1
		else:
			hi = mid - 1
	return best


def _xml_escape(text):
	return (
		(text or '')
		.replace('&', '&amp;')
		.replace('<', '&lt;')
		.replace('>', '&gt;')
		.replace('"', '&quot;')
		.replace("'", '&apos;')
	)


def speak_ssml(text, english, settings=None):
	"""短词前后加停顿；停顿长短由设置决定。"""
	settings = settings or DEFAULT_SETTINGS
	body = _xml_escape(text)
	lang = 'en-US' if english else 'zh-CN'
	pause = _speak_pause(settings.get('speak_pause'))
	pre0, post0 = SPEAK_PAUSE_MS.get(pause, SPEAK_PAUSE_MS['中'])
	short = len(text.strip()) <= 8
	pre = pre0 + (80 if short else 0)
	post = post0 + (100 if short else 0)
	# SAPI Rate 另控整体快慢；这里只做轻微相对调节
	rate = 'slow' if short else '-10%'
	return (
		"<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='%s'>"
		"<break time='%sms'/>"
		"<prosody rate='%s'>%s</prosody>"
		"<break time='%sms'/>"
		'</speak>'
	) % (lang, pre, rate, body, post)


_SPD_VOICES = None


def _spd_voice_rows():
	"""spd-say -L 里的基础音色。只要 variant 为 none 的，避免上百个变体。"""
	global _SPD_VOICES
	if _SPD_VOICES is not None:
		return _SPD_VOICES
	rows = []
	if not shutil.which('spd-say'):
		_SPD_VOICES = rows
		return rows
	try:
		proc = subprocess.run(
			['spd-say', '-L'],
			timeout=15,
			check=False,
			capture_output=True,
		)
		text = (proc.stdout or b'').decode('utf-8', errors='ignore')
	except (OSError, subprocess.SubprocessError):
		_SPD_VOICES = rows
		return rows
	lines = [line for line in text.splitlines() if line.strip()]
	if not lines or 'LANGUAGE' not in lines[0] or 'VARIANT' not in lines[0]:
		_SPD_VOICES = rows
		return rows
	lang_at = lines[0].index('LANGUAGE')
	var_at = lines[0].index('VARIANT')
	for line in lines[1:]:
		if len(line) < var_at:
			continue
		name = line[:lang_at].strip()
		lang = line[lang_at:var_at].strip()
		variant = line[var_at:].strip()
		if not name or not lang or '+' in name or variant != 'none':
			continue
		rows.append({'name': name, 'lang': lang})
	_SPD_VOICES = rows
	return rows


def _pick_spd_voice(english, settings):
	rows = _spd_voice_rows()
	chosen = _voice_name((settings or DEFAULT_SETTINGS).get('voice_en' if english else 'voice_zh'))
	if chosen and chosen != VOICE_AUTO:
		for row in rows:
			if row['name'] == chosen:
				return row
	if english:
		for lang in ('en-US', 'en-GB', 'en'):
			for row in rows:
				if row['lang'].lower() == lang.lower():
					return row
		for row in rows:
			if row['lang'].lower().startswith('en'):
				return row
		return None
	for row in rows:
		if row['lang'].lower() == 'cmn' and 'pinyin' not in row['name'].lower():
			return row
	for row in rows:
		lang = row['lang'].lower()
		if lang.startswith('cmn') or lang.startswith('zh'):
			return row
	return None


def list_system_voices():
	"""本机可用音色。返回 {'zh':[名字…], 'en':[名字…]}。失败则空列表。"""
	global _VOICE_CACHE
	if _VOICE_CACHE is not None:
		return _VOICE_CACHE
	zh, en = [], []
	if IS_WIN:
		script = (
			"Add-Type -AssemblyName System.Speech\r\n"
			"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer\r\n"
			"foreach ($v in $s.GetInstalledVoices()) {\r\n"
			"  if (-not $v.Enabled) { continue }\r\n"
			"  $info = $v.VoiceInfo\r\n"
			"  $lang = $info.Culture.TwoLetterISOLanguageName\r\n"
			"  Write-Output ($lang + '|' + $info.Name)\r\n"
			"}\r\n"
		)
		script_path = ''
		try:
			fd, script_path = tempfile.mkstemp(suffix='.ps1')
			os.close(fd)
			with open(script_path, 'w', encoding='utf-8', newline='\n') as f:
				f.write(script)
			proc = subprocess.run(
				['powershell', '-NoProfile', '-STA', '-File', script_path],
				creationflags=CREATE_NO_WINDOW,
				timeout=20,
				check=False,
				capture_output=True,
			)
			out = (proc.stdout or b'').decode('utf-8', errors='ignore')
			if not out.strip():
				out = (proc.stdout or b'').decode('gb18030', errors='ignore')
			for line in out.splitlines():
				line = line.strip()
				if '|' not in line:
					continue
				lang, name = line.split('|', 1)
				name = name.strip()
				if not name:
					continue
				if lang == 'zh':
					zh.append(name)
				elif lang == 'en':
					en.append(name)
		except (OSError, subprocess.SubprocessError, UnicodeError):
			pass
		if script_path:
			try:
				os.remove(script_path)
			except OSError:
				pass
	else:
		if _melo_ready() or _neural_model(False):
			zh.append(NEURAL_ZH)
		if _neural_model(True):
			en.append(NEURAL_EN)
		if not zh or not en:
			for row in _spd_voice_rows():
				lang = row['lang'].lower()
				if not zh and (lang.startswith('cmn') or lang.startswith('zh') or lang.startswith('yue')):
					zh.append(row['name'])
				elif not en and lang.startswith('en'):
					en.append(row['name'])
	_VOICE_CACHE = {'zh': zh, 'en': en}
	return _VOICE_CACHE


def _voice_prefer_list(english, settings):
	settings = settings or DEFAULT_SETTINGS
	chosen = _voice_name(settings.get('voice_en' if english else 'voice_zh'))
	if chosen and chosen != VOICE_AUTO:
		return [chosen]
	if english:
		return [
			'Microsoft Zira Desktop',
			'Microsoft Aria Desktop',
			'Microsoft David Desktop',
		]
	return [
		'Microsoft Huihui Desktop',
		'Microsoft Yaoyao Desktop',
		'Microsoft Kangkang Desktop',
	]


def mint_speak_cmd(text, english=False, settings=None):
	settings = settings or DEFAULT_SETTINGS
	rate = _rate_of(settings, english)
	# spd-say -r：-100~100；espeak -s：约 80~200
	spd_r = max(-80, min(20, rate * 12 - 10))
	esp_s = max(90, min(180, 145 + rate * 8))
	gap = {'短': 4, '中': 8, '长': 14}.get(_speak_pause(settings.get('speak_pause')), 8)
	if shutil.which('spd-say'):
		cmd = ['spd-say', '-o', 'espeak-ng']
		voice = _pick_spd_voice(english, settings)
		if voice:
			cmd += ['-y', voice['name'], '-l', voice['lang']]
		else:
			cmd += ['-l', 'en-US' if english else 'cmn']
		cmd += ['-r', str(spd_r), '-w', text]
		return cmd
	if shutil.which('espeak-ng'):
		voice = 'en-us' if english else 'cmn'
		return ['espeak-ng', '-v', voice, '-s', str(esp_s), '-g', str(gap), text]
	if shutil.which('espeak'):
		voice = 'en-us' if english else 'zh'
		return ['espeak', '-v', voice, '-s', str(esp_s), '-g', str(gap), text]
	return None


_LEXICON = None
_IPA_CACHE = {}
# 课本里有、词库没收录的词。读音按这个词的用法，不按单字的另一读。
_EXTRA_PINYIN = {
	'还钱': ['huan2', 'qian2'],
	'好的': ['hao3', 'de5'],
	'玄鸟': ['xuan2', 'niao3'],
	'格尺': ['ge2', 'chi3'],
	'我和你': ['wo3', 'he2', 'ni3'],
}


def _han_text(text):
	return ''.join(ch for ch in text or '' if '\u4e00' <= ch <= '\u9fff')


def _load_lexicon():
	"""简体词 → 读音列表。整词用第一条；单字要看是不是只有一种读音。"""
	global _LEXICON
	if _LEXICON is not None:
		return _LEXICON
	lex = {}
	path = os.path.join(VOICE_DIR, 'cedict_ts.u8')
	try:
		if not os.path.isfile(path):
			raise OSError(path)
		with open(path, encoding='utf-8') as handle:
			for line in handle:
				if not line or line.startswith('#'):
					continue
				parts = line.split(' ', 2)
				if len(parts) < 3 or not parts[2].startswith('['):
					continue
				simp = parts[1]
				end = parts[2].find(']')
				if end < 2:
					continue
				syllables = []
				ok = True
				for token in parts[2][1:end].split():
					syl = token.strip().lower().replace('u:', 'v').replace('ü', 'v')
					syl = ''.join(ch for ch in syl if ch.isascii() and (ch.isalpha() or ch.isdigit()))
					if len(syl) < 2 or not syl[-1].isdigit() or not syl[:-1].isalpha():
						ok = False
						break
					syllables.append(syl)
				if not ok or not syllables:
					continue
				bucket = lex.setdefault(simp, [])
				if syllables not in bucket:
					bucket.append(syllables)
	except (OSError, UnicodeError):
		lex = {}
	for word, syllables in _EXTRA_PINYIN.items():
		bucket = lex.setdefault(word, [])
		if syllables in bucket:
			bucket.remove(syllables)
		bucket.insert(0, syllables)
	_LEXICON = lex
	return lex


def _word_pinyin(text):
	"""两个字及以上，按整词或词中的词组取读音。多音字以词语为准。取不到就返回空。"""
	word = _han_text(text)
	if len(word) < 2:
		return []
	lex = _load_lexicon()
	if not lex:
		return []
	if word in lex:
		return list(lex[word][0])
	# 词尾轻声，整词没收录时仍按词语里的用法，不按单字的另一个音。
	particle = {'了': 'le5', '的': 'de5', '着': 'zhe5'}
	out = []
	i = 0
	while i < len(word):
		found = None
		for length in range(len(word) - i, 1, -1):
			part = word[i:i + length]
			if part in lex:
				found = lex[part][0]
				i += length
				break
		if found:
			out.extend(found)
			continue
		ch = word[i]
		if i > 0 and ch in particle:
			out.append(particle[ch])
			i += 1
			continue
		choices = lex.get(ch) or []
		if len(choices) == 1:
			out.extend(choices[0])
			i += 1
			continue
		return []
	return out


def _pinyin_voice_config():
	path = os.path.join(VOICE_DIR, 'zh_CN-huayan-pinyin.onnx.json')
	if os.path.isfile(path):
		return path
	src = os.path.join(VOICE_DIR, NEURAL_FILES[NEURAL_ZH] + '.json')
	try:
		with open(src, encoding='utf-8') as handle:
			data = json.load(handle)
		voice = data.get('espeak')
		if not isinstance(voice, dict):
			voice = {}
			data['espeak'] = voice
		voice['voice'] = 'cmn-latn-pinyin'
		with open(path, 'w', encoding='utf-8', newline='\n') as handle:
			json.dump(data, handle, ensure_ascii=False)
			handle.write('\n')
	except (OSError, UnicodeError, json.JSONDecodeError):
		return ''
	return path


def english_ipa(word):
	"""美式英语音标。没有音素程序时返回空字符串。"""
	word = (word or '').strip()
	if not word:
		return ''
	if word in _IPA_CACHE:
		return _IPA_CACHE[word]
	tool = os.path.join(os.path.dirname(PIPER_BIN), 'piper_phonemize')
	data = os.path.join(os.path.dirname(PIPER_BIN), 'espeak-ng-data')
	ipa = ''
	if os.path.isfile(tool) and os.path.isdir(data):
		env = os.environ.copy()
		libdir = os.path.dirname(PIPER_BIN)
		old_lib = env.get('LD_LIBRARY_PATH')
		env['LD_LIBRARY_PATH'] = libdir if not old_lib else libdir + ':' + old_lib
		try:
			proc = subprocess.run(
				[tool, '-l', 'en-us', '--espeak_data', data],
				input=(word + '\n').encode('utf-8'),
				env=env,
				timeout=8,
				check=False,
				capture_output=True,
			)
			payload = json.loads((proc.stdout or b'').decode('utf-8'))
			pieces = [str(item) for item in (payload.get('phonemes') or []) if str(item).strip()]
			if pieces:
				ipa = '/' + ''.join(pieces) + '/'
		except (OSError, subprocess.SubprocessError, UnicodeError, json.JSONDecodeError, TypeError):
			ipa = ''
	_IPA_CACHE[word] = ipa
	return ipa


def _neural_model(english):
	name = NEURAL_FILES[NEURAL_EN if english else NEURAL_ZH]
	model = os.path.join(VOICE_DIR, name)
	data = os.path.join(os.path.dirname(PIPER_BIN), 'espeak-ng-data')
	if not (os.path.isfile(PIPER_BIN) and os.access(PIPER_BIN, os.X_OK) and os.path.isdir(data)):
		return ''
	if not (os.path.isfile(model) and os.path.isfile(model + '.json')):
		return ''
	return model


def _wants_neural(english, settings):
	chosen = _voice_name((settings or DEFAULT_SETTINGS).get('voice_en' if english else 'voice_zh'))
	label = NEURAL_EN if english else NEURAL_ZH
	return (not chosen) or chosen in (VOICE_AUTO, label)


_MELO_PROC = None
_MELO_LOCK = threading.Lock()


def _melo_ready():
	"""中文 MeloTTS 和常驻程序都在。"""
	root = os.path.join(VOICE_DIR, 'vits-melo-tts-zh_en')
	binary = os.path.join(VOICE_DIR, 'sherpa', 'nn-zh-tts')
	names = ('model.onnx', 'lexicon.txt', 'tokens.txt', 'new_heteronym.fst', 'phone.fst', 'date.fst', 'number.fst')
	if not (os.path.isfile(binary) and os.access(binary, os.X_OK)):
		return False
	return all(os.path.isfile(os.path.join(root, name)) for name in names)


def _melo_stop():
	global _MELO_PROC
	proc = _MELO_PROC
	_MELO_PROC = None
	if proc is None:
		return
	try:
		if proc.poll() is None:
			proc.kill()
		proc.wait(timeout=3)
	except (OSError, subprocess.SubprocessError):
		pass


atexit.register(_melo_stop)


def _melo_proc():
	"""加载一次模型。失败返回 None。"""
	global _MELO_PROC
	if _MELO_PROC is not None and _MELO_PROC.poll() is None:
		return _MELO_PROC
	_melo_stop()
	if not _melo_ready():
		return None
	root = os.path.join(VOICE_DIR, 'vits-melo-tts-zh_en')
	binary = os.path.join(VOICE_DIR, 'sherpa', 'nn-zh-tts')
	rules = ','.join(
		os.path.join(root, name)
		for name in ('new_heteronym.fst', 'phone.fst', 'date.fst', 'number.fst')
	)
	env = os.environ.copy()
	libdir = os.path.dirname(binary)
	old_lib = env.get('LD_LIBRARY_PATH')
	env['LD_LIBRARY_PATH'] = libdir if not old_lib else libdir + ':' + old_lib
	try:
		proc = subprocess.Popen(
			[
				binary,
				os.path.join(root, 'model.onnx'),
				os.path.join(root, 'lexicon.txt'),
				os.path.join(root, 'tokens.txt'),
				rules,
			],
			stdin=subprocess.PIPE,
			stdout=subprocess.PIPE,
			stderr=subprocess.DEVNULL,
			env=env,
			cwd=root,
		)
	except (OSError, subprocess.SubprocessError):
		return None
	try:
		ready, _, _ = select.select([proc.stdout], [], [], 40)
		line = proc.stdout.readline().strip() if ready else b''
	except (OSError, ValueError):
		line = b''
	if line != b'READY' or proc.poll() is not None:
		try:
			proc.kill()
		except OSError:
			pass
		return None
	_MELO_PROC = proc
	return proc


def _warm_melo():
	"""打开程序时先把中文模型装上，第一字不用干等加载。"""
	if IS_WIN or not _melo_ready():
		return
	with _MELO_LOCK:
		_melo_proc()


def _wav_peak(path):
	"""16 位单声道的最大振幅。读不到就返回 0。"""
	try:
		with wave.open(path, 'rb') as handle:
			if handle.getsampwidth() != 2 or handle.getnchannels() != 1:
				return 0
			raw = handle.readframes(handle.getnframes())
	except (OSError, wave.Error, EOFError):
		return 0
	if len(raw) < 2:
		return 0
	samples = array.array('h')
	samples.frombytes(raw[:len(raw) // 2 * 2])
	if not samples:
		return 0
	return max(abs(item) for item in samples)


def _normalize_wav(path, target=24000):
	"""把偏低的语音拉到接近原来的响度。几乎没声音的文件不放大。"""
	try:
		with wave.open(path, 'rb') as handle:
			channels = handle.getnchannels()
			width = handle.getsampwidth()
			rate = handle.getframerate()
			raw = handle.readframes(handle.getnframes())
	except (OSError, wave.Error, EOFError):
		return False
	if channels != 1 or width != 2 or len(raw) < 2:
		return False
	samples = array.array('h')
	samples.frombytes(raw[:len(raw) // 2 * 2])
	peak = max((abs(item) for item in samples), default=0)
	if peak < 150:
		return False
	gain = min(60.0, float(target) / float(peak))
	if gain > 1.02:
		for index, item in enumerate(samples):
			value = int(item * gain)
			if value > 32767:
				value = 32767
			elif value < -32768:
				value = -32768
			samples[index] = value
		try:
			with wave.open(path, 'wb') as handle:
				handle.setnchannels(1)
				handle.setsampwidth(2)
				handle.setframerate(rate)
				handle.writeframes(samples.tobytes())
		except OSError:
			return False
	return True


def _melo_render(proc, wav, speed, spoken):
	"""让常驻进程写 wav。有足够音量才算成功。进程死了就关掉。"""
	if proc.poll() is not None:
		_melo_stop()
		return False
	try:
		proc.stdin.write(('%s\t%.3f\t%s\n' % (wav, speed, spoken)).encode('utf-8'))
		proc.stdin.flush()
		ready, _, _ = select.select([proc.stdout], [], [], 20)
		line = proc.stdout.readline().strip() if ready else b''
	except (OSError, UnicodeError, ValueError):
		_melo_stop()
		return False
	if line != b'OK' or not os.path.isfile(wav) or os.path.getsize(wav) < 44:
		if not ready or proc.poll() is not None:
			_melo_stop()
		return False
	return _wav_peak(wav) >= 150


def _speak_melo(text, settings):
	"""中文走常驻的 MeloTTS。没准备好或这一句失败则返回 False，交给后面的语音。"""
	settings = settings or DEFAULT_SETTINGS
	if is_english_item(text) or not _wants_neural(False, settings):
		return False
	spoken = (text or '').strip().replace('\t', ' ').replace('\n', ' ').replace('\r', ' ')
	if not spoken or not _melo_ready():
		return False
	han = _han_text(spoken)
	if len(han) < 2:
		return False
	speed = 1.0 / _speech_length(settings, False)
	with _MELO_LOCK:
		proc = _melo_proc()
		if proc is None:
			return False
		fd, wav = tempfile.mkstemp(suffix='.wav', prefix='nn-shengzi-')
		os.close(fd)
		try:
			ok = _melo_render(proc, wav, speed, spoken)
			if not ok and proc is not None and not spoken.endswith('。'):
				proc = _melo_proc()
				ok = proc is not None and _melo_render(proc, wav, speed, spoken + '。')
			if not ok or not _normalize_wav(wav):
				return False
			_play_wav(wav)
			return True
		except (OSError, subprocess.SubprocessError, UnicodeError):
			_melo_stop()
			return False
		finally:
			try:
				os.remove(wav)
			except OSError:
				pass


def _play_wav(path):
	for cmd in (
		['pw-play', path],
		['paplay', path],
		['aplay', '-q', path],
	):
		if shutil.which(cmd[0]):
			subprocess.run(cmd, timeout=60, check=False)
			return


def _speak_neural(text, settings):
	"""Mint 上用 Piper。中文普通话，英文美式。文件不齐或失败则返回 False。"""
	settings = settings or DEFAULT_SETTINGS
	english = is_english_item(text)
	if not english and _speak_melo(text, settings):
		return True
	if not _wants_neural(english, settings):
		return False
	model = _neural_model(english)
	if not model:
		return False
	length = _speech_length(settings, english)
	spoken = text.strip()
	config = ''
	if not english:
		syllables = _word_pinyin(text)
		if syllables:
			spoken = ' '.join(syllables)
			config = _pinyin_voice_config()
			if not config:
				spoken = text.strip()
	if _piper_to_speaker(model, spoken, config, length):
		return True
	if spoken != text.strip():
		return _piper_to_speaker(model, text.strip(), '', length)
	return False


def _piper_env():
	env = os.environ.copy()
	libdir = os.path.dirname(PIPER_BIN)
	old_lib = env.get('LD_LIBRARY_PATH')
	env['LD_LIBRARY_PATH'] = libdir if not old_lib else libdir + ':' + old_lib
	return env, libdir


def _piper_to_speaker(model, spoken, config, length):
	fd, wav = tempfile.mkstemp(suffix='.wav', prefix='nn-shengzi-')
	os.close(fd)
	try:
		env, libdir = _piper_env()
		cmd = [
			PIPER_BIN, '-q',
			'-m', model,
			'-f', wav,
			'--length_scale', '%.2f' % length,
			'--espeak_data', os.path.join(libdir, 'espeak-ng-data'),
		]
		if config:
			cmd.extend(['-c', config])
		proc = subprocess.run(
			cmd,
			input=(spoken.strip() + '\n').encode('utf-8'),
			env=env,
			timeout=30,
			check=False,
			capture_output=True,
		)
		if proc.returncode != 0 or os.path.getsize(wav) < 44:
			return False
		_play_wav(wav)
		return True
	except (OSError, subprocess.SubprocessError):
		return False
	finally:
		try:
			os.remove(wav)
		except OSError:
			pass


def speak(text, settings=None):
	"""Windows 用系统语音，Mint 优先用自然语音。失败静默，不退出。"""
	if not text:
		return
	settings = settings or DEFAULT_SETTINGS
	try:
		if IS_WIN:
			_speak_win(text, settings)
		elif not _speak_neural(text, settings):
			_speak_mint(text, settings)
	except (OSError, subprocess.SubprocessError):
		pass


def _speak_win(text, settings=None):
	settings = settings or DEFAULT_SETTINGS
	english = is_english_item(text)
	ssml = speak_ssml(text, english, settings)
	payload = base64.b64encode(ssml.encode('utf-8')).decode('ascii')
	rate = _rate_of(settings, english)
	prefer_names = _voice_prefer_list(english, settings)
	# PowerShell 字符串数组字面量
	prefer = '(' + ','.join("'%s'" % name.replace("'", "''") for name in prefer_names) + ')'
	culture = 'en' if english else 'zh'
	script = (
		"Add-Type -AssemblyName System.Speech\n"
		"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer\n"
		"$s.Rate = %s\n"
		"$prefer = @%s\n"
		"$picked = $false\n"
		"foreach ($name in $prefer) {\n"
		"  try { $s.SelectVoice($name); $picked = $true; break } catch {}\n"
		"}\n"
		"if (-not $picked) {\n"
		"  foreach ($v in $s.GetInstalledVoices()) {\n"
		"    if ($v.Enabled -and $v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq '%s') {\n"
		"      try { $s.SelectVoice($v.VoiceInfo.Name); break } catch {}\n"
		"    }\n"
		"  }\n"
		"}\n"
		"$ssml = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('%s'))\n"
		"$s.SpeakSsml($ssml)\n"
	) % (rate, prefer, culture, payload)
	encoded = base64.b64encode(script.encode('utf-16-le')).decode('ascii')
	subprocess.run(
		['powershell', '-NoProfile', '-STA', '-EncodedCommand', encoded],
		creationflags=CREATE_NO_WINDOW,
		timeout=60,
		check=False,
	)


def _speak_mint(text, settings=None):
	cmd = mint_speak_cmd(text, english=is_english_item(text), settings=settings)
	if not cmd:
		return
	subprocess.run(cmd, timeout=60, check=False)


class LearnApp(tk.Tk):
	def __init__(self, settings):
		super().__init__()
		self.settings = settings
		self.look_ms = settings['look_seconds'] * 1000
		self.decks, self.glosses = load_decks(settings)
		self.show_gloss = False
		self.mode = initial_mode(self.decks, settings)
		self.queue_items = []
		self.known = 0
		self.total = 0
		self.current = ''
		self.phase = 'idle'
		self.round_id = 0
		self.after_id = None
		self.poll_id = None
		self.alive = True
		self.events = queue.Queue()
		self.settings_dialog = None
		self.catalog = load_catalog()
		self.title('年年识字')
		self.configure(bg='white')
		self.minsize(800, 560)
		self.font_family = pick_font_family(self)
		self._build()
		self._place()
		self.protocol('WM_DELETE_WINDOW', self.close)
		self.bind('<Escape>', lambda event: self.close())
		self.bind('<space>', lambda event: self.speak_now())
		self.start_round(self.mode)
		self.poll_id = self.after(150, self._poll)
		self.after(400, lambda: self.refresh_catalog(quiet=True))

	def _build(self):
		ui_font = (self.font_family, 16)
		top = tk.Frame(self, bg='white')
		top.pack(fill='x', padx=16, pady=(12, 0))
		tk.Label(top, text='年年识字', bg='white', fg='#222222', font=(self.font_family, 20)).pack(side='left')
		tk.Label(top, text='幼儿识字 · %s' % __version__, bg='white', fg='#888888', font=(self.font_family, 13)).pack(side='left', padx=(8, 0))
		tk.Button(top, text='设置', font=ui_font, command=self.open_settings, padx=12, pady=4).pack(side='left', padx=(12, 0))
		self.mode_bar = top
		self.mode_buttons = {}
		self._rebuild_mode_buttons()
		source = tk.Frame(self, bg='white')
		source.pack(fill='x', padx=16, pady=(10, 0))
		tk.Label(source, text='生字来源', bg='white', fg='#222222', font=ui_font).pack(side='left')
		self.source_btn = tk.Button(
			source,
			text='未选',
			font=ui_font,
			relief='raised',
			padx=10,
			pady=4,
			command=self.open_source_picker,
		)
		self.source_btn.pack(side='left', padx=(8, 0))
		self.source_picker = None
		tk.Button(source, text='刷新课表', font=ui_font, command=self.refresh_catalog, padx=10, pady=4).pack(side='left', padx=8)
		self._paint_source_button()
		self.canvas = tk.Canvas(self, bg='white', highlightthickness=0, borderwidth=0)
		self.canvas.pack(fill='both', expand=True, padx=12, pady=8)
		self.canvas.bind('<Configure>', self._on_canvas_configure)
		self._draw_size = (0, 0)
		bar = tk.Frame(self, bg='white')
		bar.pack(fill='x', padx=16, pady=(0, 6))
		self.speak_btn = tk.Button(bar, text='朗读', font=ui_font, command=self.speak_now, padx=18, pady=6)
		self.known_btn = tk.Button(bar, text='记住了', font=ui_font, command=self.mark_known, padx=18, pady=6)
		self.again_btn = tk.Button(bar, text='下一个词', font=ui_font, command=self.mark_again, padx=18, pady=6)
		self.gloss_btn = tk.Button(bar, text='翻译', font=ui_font, command=self.toggle_gloss, padx=18, pady=6)
		self.speak_btn.pack(side='left', padx=(0, 8))
		self.known_btn.pack(side='left', padx=8)
		self.again_btn.pack(side='left', padx=8)
		self.gloss_btn.pack(side='left', padx=8)
		self.status = tk.Label(self, text='', bg='white', fg='#666666', font=(self.font_family, 13))
		self.status.pack(fill='x', padx=16, pady=(0, 12))

	def _rebuild_mode_buttons(self):
		for btn in self.mode_buttons.values():
			btn.destroy()
		self.mode_buttons = {}
		ui_font = (self.font_family, 16)
		for name, _items in self.decks:
			btn = tk.Button(
				self.mode_bar,
				text=name,
				font=ui_font,
				command=lambda n=name: self.start_round(n),
				padx=12,
				pady=4,
			)
			btn.pack(side='right', padx=4)
			self.mode_buttons[name] = btn
		if hasattr(self, 'phase'):
			self._paint_modes()

	def _source_options(self):
		options = [(SOURCE_BUILTIN, '简单汉字'), (SOURCE_CUSTOM, '自定义文本')]
		for row in self.catalog:
			options.append((row['id'], row['title']))
		return options

	def _paint_source_button(self):
		selected = set(self.settings.get('book_ids') or [])
		names = [title for book_id, title in self._source_options() if book_id in selected]
		if not names:
			text = '未选 · 点这里选'
		elif len(names) <= 2:
			text = '、'.join(names)
		else:
			text = '已选 %s 个' % len(names)
		self.source_btn.configure(text=text)

	def open_source_picker(self):
		if self.source_picker is not None:
			try:
				if self.source_picker.winfo_exists():
					self.source_picker.lift()
					self.source_picker.focus_force()
					return
			except tk.TclError:
				self.source_picker = None
		if self.phase == 'look':
			self._cancel()
		self.source_picker = SourcePickerDialog(self)

	def apply_sources(self, book_ids):
		self.source_picker = None
		self.settings['book_ids'] = list(book_ids)
		self.settings = save_settings(self.settings)
		self._paint_source_button()
		self.refresh_catalog(quiet=False, restart=True)

	def refresh_catalog(self, quiet=False, restart=False):
		if not quiet and self.phase != 'speak':
			self.status.configure(text='正在刷新课表')
		threading.Thread(target=self._refresh_worker, args=(quiet, restart), daemon=True).start()

	def _refresh_worker(self, quiet, restart):
		err = ''
		items = None
		try:
			items = fetch_catalog()
			missed = download_selected(items, self.settings.get('book_ids') or [])
			if missed:
				err = '没取到：' + '、'.join(missed)
		except (OSError, UnicodeError, urllib.error.URLError, json.JSONDecodeError):
			err = '课表暂时取不到，仍用上次保存的内容'
		self.events.put(('catalog', items, err, quiet, restart))

	def _on_catalog(self, items, err, quiet, restart):
		if items is not None:
			self.catalog = items
			self._paint_source_button()
			if self.source_picker is not None:
				try:
					if self.source_picker.winfo_exists():
						self.source_picker.reload_options()
				except tk.TclError:
					self.source_picker = None
			selected = set(self.settings.get('book_ids') or [])
			remote = any(row.get('id') in selected for row in items)
			if restart or (remote and self.mode == '生字'):
				self.decks, self.glosses = load_decks(self.settings)
				self._rebuild_mode_buttons()
				if restart or self.mode == '生字':
					self.start_round(self.mode)
					if err:
						self.status.configure(text=err)
					return
		if err and not quiet and self.phase != 'speak':
			self.status.configure(text=err)

	def _place(self):
		self.update_idletasks()
		w, h = 1024, 700
		x = max((self.winfo_screenwidth() - w) // 2, 0)
		y = max((self.winfo_screenheight() - h) // 2, 0)
		self.geometry('%sx%s+%s+%s' % (w, h, x, y))

	def _cancel(self):
		if self.after_id is not None:
			self.after_cancel(self.after_id)
			self.after_id = None

	def close(self):
		self.alive = False
		self.round_id += 1
		self._cancel()
		if self.poll_id is not None:
			self.after_cancel(self.poll_id)
			self.poll_id = None
		self.destroy()

	def items_of(self, name):
		for deck_name, items in self.decks:
			if deck_name == name:
				return list(items)
		return []

	def start_round(self, name):
		self._cancel()
		self.round_id += 1
		self.mode = name
		if name != '单词':
			self.show_gloss = False
		items = self.items_of(name)
		random.shuffle(items)
		self.queue_items = items
		self.total = len(items)
		self.known = 0
		self._paint_modes()
		self._restore_buttons()
		self._paint_gloss_button()
		self.show_next()

	def _paint_modes(self):
		for name, btn in self.mode_buttons.items():
			if name == self.mode:
				btn.configure(relief='sunken', bg='#d9ead3')
			else:
				btn.configure(relief='raised', bg='#f3f3f3')

	def _restore_buttons(self):
		self.speak_btn.configure(state='normal', text='朗读')
		self.known_btn.configure(state='normal', text='记住了', command=self.mark_known)
		self.again_btn.configure(state='normal', text='下一个词', command=self.mark_again)
		self._paint_gloss_button()

	def _paint_gloss_button(self):
		on_word = self.mode == '单词'
		state = 'normal' if on_word else 'disabled'
		if self.show_gloss and on_word:
			self.gloss_btn.configure(state=state, text='翻译✓', relief='sunken', bg='#d9ead3')
		else:
			self.gloss_btn.configure(state=state, text='翻译', relief='raised', bg='#f3f3f3')

	def toggle_gloss(self):
		if self.mode != '单词':
			return
		self.show_gloss = not self.show_gloss
		self._paint_gloss_button()
		if self.current and self.phase not in ('done', 'empty'):
			self.redraw_current()

	def gloss_of(self, word):
		return (self.glosses or {}).get(word or '', '')

	def redraw_current(self):
		if self.phase == 'done':
			self.draw('本轮记住了')
		elif self.phase == 'empty':
			self.draw('还没有内容')
		elif self.current:
			self.draw(self.current)

	def show_next(self):
		if not self.alive:
			return
		if not self.queue_items:
			if self.known == 0:
				self.current = ''
				self.phase = 'empty'
				self.draw('还没有内容')
				self.speak_btn.configure(state='disabled')
				self.again_btn.configure(state='disabled')
				self.known_btn.configure(state='disabled')
				self.status.configure(text='%s · 这个来源里还没有生字或单词' % self.mode)
				return
			self.current = ''
			self.phase = 'done'
			self.draw('本轮记住了')
			self.speak_btn.configure(state='disabled')
			self.again_btn.configure(state='disabled')
			self.known_btn.configure(text='再来一轮', command=lambda: self.start_round(self.mode))
			self.status.configure(text='%s · 这一轮 %s 个都记住了' % (self.mode, self.known))
			return
		self.current = self.queue_items[0]
		self.redraw_current()
		self.phase = 'look'
		left = len(self.queue_items)
		self._status_look(left)
		self.focus_force()
		if self.settings.get('auto_speak', True):
			self.after_id = self.after(self.look_ms, self.finish_look)

	def _status_look(self, left):
		if self.settings.get('auto_speak', True):
			tip = '看 %s 秒后朗读，听完再点记住了或下一个词' % self.settings['look_seconds']
		else:
			tip = '看完点朗读，听完再点记住了或下一个词'
		self.status.configure(text='%s · 已记住 %s · 还剩 %s · %s' % (self.mode, self.known, left, tip))

	def open_settings(self):
		if self.settings_dialog is not None:
			try:
				if self.settings_dialog.winfo_exists():
					self.settings_dialog.lift()
					return
			except tk.TclError:
				self.settings_dialog = None
		if self.phase == 'look':
			self._cancel()
		self.settings_dialog = SettingsDialog(self)

	def apply_settings(self, settings, text_changed):
		self.settings_dialog = None
		self.settings = settings
		self.look_ms = settings['look_seconds'] * 1000
		self.decks, self.glosses = load_decks(settings)
		self._rebuild_mode_buttons()
		names = [name for name, items in self.decks if items]
		if text_changed and '自定义' in names:
			self.start_round('自定义')
			return
		if self.mode not in names:
			self.start_round('生字' if '生字' in names else names[0])
			return
		self._paint_modes()
		self._paint_gloss_button()
		if self.current and self.phase not in ('done', 'empty'):
			self.redraw_current()
		if self.phase == 'look':
			self._status_look(len(self.queue_items))
			if settings.get('auto_speak', True):
				self.after_id = self.after(self.look_ms, self.finish_look)

	def resume_after_settings(self):
		self.settings_dialog = None
		if self.alive and self.phase == 'look' and self.settings.get('auto_speak', True) and self.after_id is None:
			self.after_id = self.after(self.look_ms, self.finish_look)

	def _on_canvas_configure(self, event):
		if event.width < 50 or event.height < 50:
			return
		size = (event.width, event.height)
		if size == self._draw_size:
			return
		self._draw_size = size
		self.redraw_current()

	def _latin_font(self):
		cached = getattr(self, '_latin_font_name', '')
		if cached:
			return cached
		families = set(tkfont.families(self))
		chosen = self.font_family
		for name in ('DejaVu Sans', 'Noto Sans', 'FreeSans', 'Liberation Sans'):
			if name in families:
				chosen = name
				break
		self._latin_font_name = chosen
		return chosen

	def draw(self, text):
		width = max(self.canvas.winfo_width(), 200)
		height = max(self.canvas.winfo_height(), 160)
		self.canvas.delete('all')
		gloss = ''
		if self.show_gloss and self.mode == '单词' and text == self.current:
			gloss = self.gloss_of(text)
		ipa = ''
		if self.settings.get('show_ipa') and self.mode == '单词' and text == self.current and is_english_item(text):
			ipa = english_ipa(text)
		if gloss or ipa:
			main_h = int(height * (0.5 if gloss and ipa else 0.62))
			size = fit_font_size(self.font_family, text, width, main_h)
			word_y = height * (0.32 if gloss and ipa else 0.38)
			self.canvas.create_text(
				width / 2,
				word_y,
				text=text,
				fill='#000000',
				font=(self.font_family, size),
				anchor='center',
			)
			note_size = max(18, min(40, size // 2))
			if ipa:
				self.canvas.create_text(
					width / 2,
					height * (0.56 if gloss else 0.72),
					text=ipa,
					fill='#333333',
					font=(self._latin_font(), note_size),
					anchor='center',
					width=max(width - 80, 120),
					justify='center',
				)
			if gloss:
				self.canvas.create_text(
					width / 2,
					height * (0.76 if ipa else 0.72),
					text=gloss,
					fill='#444444',
					font=(self.font_family, note_size),
					anchor='center',
				)
			return
		size = fit_font_size(self.font_family, text, width, height)
		self.canvas.create_text(
			width / 2,
			height / 2,
			text=text,
			fill='#000000',
			font=(self.font_family, size),
			anchor='center',
		)

	def finish_look(self):
		if not self.alive or self.phase != 'look':
			return
		self.speak_now()

	def speak_now(self):
		if not self.alive or self.phase in ('speak', 'done', 'empty') or not self.current:
			return
		self._cancel()
		self.phase = 'speak'
		self.status.configure(text='正在朗读')
		self.update_idletasks()
		text = self.current
		token = self.round_id
		threading.Thread(target=self._speak_worker, args=(text, token), daemon=True).start()

	def _speak_worker(self, text, token):
		try:
			speak(text, self.settings)
		finally:
			self.events.put(token)

	def _poll(self):
		if not self.alive:
			return
		try:
			while True:
				token = self.events.get_nowait()
				if isinstance(token, tuple) and token and token[0] == 'catalog':
					self._on_catalog(token[1], token[2], token[3], token[4])
					continue
				if token == self.round_id and self.phase == 'speak':
					self.phase = 'mark'
					left = len(self.queue_items)
					self.status.configure(text='%s · 已记住 %s · 还剩 %s · 点「记住了」或「下一个词」' % (self.mode, self.known, left))
		except queue.Empty:
			pass
		self.poll_id = self.after(150, self._poll)

	def mark_known(self):
		if self.phase not in ('look', 'mark') or not self.queue_items:
			return
		self._cancel()
		self.round_id += 1
		self.queue_items.pop(0)
		self.known += 1
		self.show_next()

	def mark_again(self):
		# 下一个词 = 当前词排到队尾（本轮稍后还会再碰到），先练后面的。
		if self.phase not in ('look', 'mark') or not self.queue_items:
			return
		self._cancel()
		self.round_id += 1
		item = self.queue_items.pop(0)
		self.queue_items.append(item)
		self.show_next()


class SourcePickerDialog(tk.Toplevel):
	MARK_ON = '✓'
	MARK_OFF = '□'

	def __init__(self, app):
		super().__init__(app)
		self.app = app
		self.title('选择生字来源')
		self.configure(bg='white')
		self.transient(app)
		self.minsize(520, 480)
		self.font_family = app.font_family
		self.selected = set(app.settings.get('book_ids') or [])
		self.row_ids = []
		self.row_titles = []
		self._build()
		self._place(app)
		self.protocol('WM_DELETE_WINDOW', self.cancel)
		self.bind('<Escape>', lambda event: self.cancel())
		self.grab_set()
		self.focus_force()

	def _place(self, app):
		self.update_idletasks()
		w, h = 560, 560
		x = app.winfo_rootx() + max((app.winfo_width() - w) // 2, 0)
		y = app.winfo_rooty() + max((app.winfo_height() - h) // 2, 0)
		self.geometry('%sx%s+%s+%s' % (w, h, x, y))

	def _build(self):
		ui = (self.font_family, 15)
		tk.Label(
			self,
			text='用对号和空方框多选。可以同时选几本课本。',
			bg='white',
			fg='#555555',
			font=(self.font_family, 13),
			anchor='w',
		).pack(fill='x', padx=18, pady=(14, 6))
		box = tk.Frame(self, bg='white')
		box.pack(fill='both', expand=True, padx=18, pady=4)
		scroll = tk.Scrollbar(box)
		scroll.pack(side='right', fill='y')
		self.listbox = tk.Listbox(
			box,
			font=(self.font_family, 18),
			activestyle='none',
			selectmode='browse',
			highlightthickness=1,
			relief='solid',
			borderwidth=1,
			yscrollcommand=scroll.set,
		)
		self.listbox.pack(side='left', fill='both', expand=True)
		scroll.configure(command=self.listbox.yview)
		self.listbox.bind('<Button-1>', self._on_click)
		self.listbox.bind('<space>', self._on_space)
		self.listbox.bind('<Return>', self._on_space)
		hint = tk.Frame(self, bg='white')
		hint.pack(fill='x', padx=18, pady=(4, 0))
		tk.Label(hint, text='%s 已选' % self.MARK_ON, bg='white', fg='#0b6e4f', font=(self.font_family, 13)).pack(side='left')
		tk.Label(hint, text='%s 未选' % self.MARK_OFF, bg='white', fg='#666666', font=(self.font_family, 13)).pack(side='left', padx=(16, 0))
		bottom = tk.Frame(self, bg='white')
		bottom.pack(fill='x', padx=18, pady=12)
		tk.Button(bottom, text='全不选', font=ui, command=self.clear_all, padx=12, pady=4).pack(side='left')
		tk.Button(bottom, text='取消', font=ui, command=self.cancel, padx=16, pady=4).pack(side='right')
		tk.Button(bottom, text='确定', font=ui, command=self.save, padx=16, pady=4).pack(side='right', padx=(0, 8))
		self.reload_options()

	def reload_options(self):
		options = self.app._source_options()
		keep = set(self.selected)
		self.row_ids = [book_id for book_id, _title in options]
		self.row_titles = [title for _book_id, title in options]
		self.selected = {book_id for book_id in self.row_ids if book_id in keep}
		self.listbox.delete(0, 'end')
		for book_id, title in options:
			mark = self.MARK_ON if book_id in self.selected else self.MARK_OFF
			self.listbox.insert('end', '%s  %s' % (mark, title))
			if book_id in self.selected:
				self.listbox.itemconfigure('end', fg='#0b6e4f')
			else:
				self.listbox.itemconfigure('end', fg='#222222')

	def _toggle(self, index):
		if index < 0 or index >= len(self.row_ids):
			return
		book_id = self.row_ids[index]
		title = self.row_titles[index]
		if book_id in self.selected:
			self.selected.discard(book_id)
			mark = self.MARK_OFF
			color = '#222222'
		else:
			self.selected.add(book_id)
			mark = self.MARK_ON
			color = '#0b6e4f'
		self.listbox.delete(index)
		self.listbox.insert(index, '%s  %s' % (mark, title))
		self.listbox.itemconfigure(index, fg=color)
		self.listbox.selection_clear(0, 'end')
		self.listbox.selection_set(index)
		self.listbox.activate(index)

	def _on_click(self, event):
		index = self.listbox.nearest(event.y)
		self._toggle(index)
		return 'break'

	def _on_space(self, event):
		index = self.listbox.curselection()
		if index:
			self._toggle(index[0])
		return 'break'

	def clear_all(self):
		self.selected.clear()
		self.reload_options()

	def cancel(self):
		self.destroy()
		self.app.source_picker = None

	def save(self):
		# 保持课表顺序，不要打乱 book_ids
		ordered = [book_id for book_id, _title in self.app._source_options() if book_id in self.selected]
		self.destroy()
		self.app.apply_sources(ordered)


class SettingsDialog(tk.Toplevel):
	def __init__(self, app):
		super().__init__(app)
		self.app = app
		self.title('设置')
		self.configure(bg='white')
		self.transient(app)
		self.minsize(680, 560)
		self.font_family = app.font_family
		self.page = '常规'
		self._build()
		self._place(app)
		self.protocol('WM_DELETE_WINDOW', self.cancel)
		self.bind('<Escape>', self.cancel)
		self.grab_set()
		self.show_page('常规')

	def _place(self, app):
		self.update_idletasks()
		w, h = 760, 620
		x = app.winfo_rootx() + max((app.winfo_width() - w) // 2, 0)
		y = app.winfo_rooty() + max((app.winfo_height() - h) // 2, 0)
		self.geometry('%sx%s+%s+%s' % (w, h, x, y))

	def _build(self):
		ui = (self.font_family, 14)
		body = tk.Frame(self, bg='white')
		body.pack(fill='both', expand=True)
		nav = tk.Frame(body, bg='#f6f6f6', width=150)
		nav.pack(side='left', fill='y')
		nav.pack_propagate(False)
		self.nav = {}
		for i, name in enumerate(('常规', '语音', '自定义文本')):
			btn = tk.Button(nav, text=name, font=ui, command=lambda n=name: self.show_page(n), padx=8, pady=8)
			btn.pack(fill='x', padx=10, pady=(12 if i == 0 else 6, 0))
			self.nav[name] = btn
		self.host = tk.Frame(body, bg='white')
		self.host.pack(side='left', fill='both', expand=True, padx=16, pady=12)
		self.page_general = self._build_general(ui)
		self.page_voice = self._build_voice(ui)
		self.page_text = self._build_text(ui)
		bottom = tk.Frame(self, bg='white')
		bottom.pack(fill='x', padx=16, pady=(0, 12))
		self.message = tk.Label(bottom, text='', bg='white', fg='#a33', font=(self.font_family, 12), anchor='w')
		self.message.pack(side='left', fill='x', expand=True)
		tk.Button(bottom, text='取消', font=ui, command=self.cancel, padx=16, pady=4).pack(side='right')
		tk.Button(bottom, text='保存', font=ui, command=self.save, padx=16, pady=4).pack(side='right', padx=(0, 8))

	def _build_general(self, ui):
		page = tk.Frame(self.host, bg='white')
		settings = self.app.settings
		tk.Label(page, text='看字时间（秒）', bg='white', font=ui).grid(row=0, column=0, sticky='w', pady=8)
		self.look_var = tk.StringVar(value=str(settings['look_seconds']))
		tk.Spinbox(page, from_=1, to=120, textvariable=self.look_var, font=ui, width=8).grid(row=0, column=1, sticky='w', padx=(12, 0))
		tk.Label(page, text='启动时进入', bg='white', font=ui).grid(row=1, column=0, sticky='w', pady=8)
		self.start_var = tk.StringVar(value=settings.get('start_deck') or '生字')
		tk.OptionMenu(page, self.start_var, *DECK_NAMES).grid(row=1, column=1, sticky='w', padx=(12, 0))
		tk.Label(
			page,
			text='启动项要等下次打开程序才生效。当前这一轮不会被打断。朗读相关请到「语音」页。',
			bg='white',
			fg='#666666',
			font=(self.font_family, 12),
			wraplength=460,
			justify='left',
		).grid(row=2, column=0, columnspan=2, sticky='w', pady=(12, 0))
		return page

	def _build_voice(self, ui):
		page = tk.Frame(self.host, bg='white')
		settings = self.app.settings
		voices = list_system_voices()
		zh_choices = [VOICE_AUTO] + list(voices.get('zh') or [])
		en_choices = [VOICE_AUTO] + list(voices.get('en') or [])
		cur_zh = _voice_name(settings.get('voice_zh'))
		cur_en = _voice_name(settings.get('voice_en'))
		if cur_zh not in zh_choices:
			zh_choices.append(cur_zh)
		if cur_en not in en_choices:
			en_choices.append(cur_en)

		self.auto_var = tk.BooleanVar(value=bool(settings.get('auto_speak', True)))
		tk.Checkbutton(page, text='看完自动朗读', variable=self.auto_var, bg='white', font=ui, anchor='w').grid(
			row=0, column=0, columnspan=2, sticky='w', pady=8
		)

		self.rate_zh_var = self._add_rate_row(page, ui, 1, '中文语速', settings.get('speak_rate_zh'))
		self.rate_en_var = self._add_rate_row(page, ui, 2, '英文语速', settings.get('speak_rate_en'))

		tk.Label(page, text='词前后停顿', bg='white', font=ui).grid(row=3, column=0, sticky='w', pady=8)
		self.pause_var = tk.StringVar(value=_speak_pause(settings.get('speak_pause')))
		tk.OptionMenu(page, self.pause_var, *SPEAK_PAUSE_CHOICES).grid(row=3, column=1, sticky='w', padx=(12, 0))

		tk.Label(page, text='中文音色', bg='white', font=ui).grid(row=4, column=0, sticky='w', pady=8)
		self.voice_zh_var = tk.StringVar(value=cur_zh)
		tk.OptionMenu(page, self.voice_zh_var, *zh_choices).grid(row=4, column=1, sticky='w', padx=(12, 0))

		tk.Label(page, text='英文音色', bg='white', font=ui).grid(row=5, column=0, sticky='w', pady=8)
		self.voice_en_var = tk.StringVar(value=cur_en)
		tk.OptionMenu(page, self.voice_en_var, *en_choices).grid(row=5, column=1, sticky='w', padx=(12, 0))

		self.ipa_var = tk.BooleanVar(value=bool(settings.get('show_ipa', False)))
		tk.Checkbutton(page, text='单词显示音标', variable=self.ipa_var, bg='white', font=ui, anchor='w').grid(
			row=6, column=0, columnspan=2, sticky='w', pady=8
		)

		trial = tk.Frame(page, bg='white')
		trial.grid(row=7, column=0, columnspan=2, sticky='w', pady=(16, 0))
		tk.Button(trial, text='试听中文', font=ui, command=lambda: self._trial_speak('银行'), padx=12, pady=4).pack(side='left')
		tk.Button(trial, text='试听英文', font=ui, command=lambda: self._trial_speak('hello'), padx=12, pady=4).pack(
			side='left', padx=(8, 0)
		)
		tk.Label(
			page,
			text='「自动」在 Windows 上选系统中文或英文语音；在 Mint 上中文用带词库的普通话，英文用美式英语。中文和英文语速分开调。改完可先试听，再点保存。',
			bg='white',
			fg='#666666',
			font=(self.font_family, 12),
			wraplength=480,
			justify='left',
		).grid(row=8, column=0, columnspan=2, sticky='w', pady=(14, 0))
		if not voices.get('zh') and not voices.get('en') and not IS_WIN:
			tk.Label(
				page,
				text='当前系统未列出可选音色，将使用本机默认朗读引擎。',
				bg='white',
				fg='#888888',
				font=(self.font_family, 12),
			).grid(row=9, column=0, columnspan=2, sticky='w', pady=(8, 0))
		return page

	def _rate_text(self, value):
		rate = _speak_rate(value)
		if rate <= -4:
			return '很慢'
		if rate <= -2:
			return '较慢'
		if rate <= 0:
			return '适中'
		if rate <= 2:
			return '较快'
		return '很快'

	def _add_rate_row(self, page, ui, row, title, initial):
		tk.Label(page, text=title, bg='white', font=ui).grid(row=row, column=0, sticky='w', pady=8)
		rate_row = tk.Frame(page, bg='white')
		rate_row.grid(row=row, column=1, sticky='w', padx=(12, 0))
		var = tk.IntVar(value=_speak_rate(initial))
		label = tk.Label(rate_row, text=self._rate_text(var.get()), bg='white', font=ui, width=8, anchor='w')

		def on_slide(_value, label=label, var=var):
			label.configure(text=self._rate_text(var.get()))

		tk.Scale(
			rate_row,
			from_=-5,
			to=5,
			orient='horizontal',
			variable=var,
			length=220,
			showvalue=0,
			command=on_slide,
			bg='white',
			highlightthickness=0,
		).pack(side='left')
		label.pack(side='left', padx=(8, 0))
		tk.Label(rate_row, text='慢 ← → 快', bg='white', fg='#888888', font=(self.font_family, 11)).pack(side='left', padx=(8, 0))
		return var

	def _draft_voice_settings(self):
		return {
			'auto_speak': bool(self.auto_var.get()),
			'speak_rate_zh': _speak_rate(self.rate_zh_var.get()),
			'speak_rate_en': _speak_rate(self.rate_en_var.get()),
			'speak_pause': _speak_pause(self.pause_var.get()),
			'voice_zh': _voice_name(self.voice_zh_var.get()),
			'voice_en': _voice_name(self.voice_en_var.get()),
			'show_ipa': bool(self.ipa_var.get()),
		}

	def _trial_speak(self, text):
		draft = dict(self.app.settings)
		draft.update(self._draft_voice_settings())
		self.message.configure(text='正在试听…', fg='#666666')
		self.update_idletasks()

		def worker():
			try:
				speak(text, draft)
			finally:
				self.after(0, lambda: self.message.configure(text='', fg='#a33'))

		threading.Thread(target=worker, daemon=True).start()

	def _build_text(self, ui):
		page = tk.Frame(self.host, bg='white')
		tk.Label(page, text='文本位置', bg='white', font=ui).pack(anchor='w')
		row = tk.Frame(page, bg='white')
		row.pack(fill='x', pady=(6, 8))
		self.path_var = tk.StringVar(value=self.app.settings.get('text_path') or '')
		tk.Entry(row, textvariable=self.path_var, font=ui).pack(side='left', fill='x', expand=True)
		tk.Button(row, text='浏览', font=ui, command=self.browse, padx=12, pady=2).pack(side='left', padx=(8, 0))
		tk.Label(
			page,
			text='一行一条。整份只有一行、而且没有逗号时，按单字拆开。',
			bg='white',
			fg='#666666',
			font=(self.font_family, 12),
		).pack(anchor='w', pady=(0, 6))
		box = tk.Frame(page, bg='white')
		box.pack(fill='both', expand=True)
		scroll = tk.Scrollbar(box)
		scroll.pack(side='right', fill='y')
		self.editor = tk.Text(box, font=(self.font_family, 14), wrap='word', undo=True, yscrollcommand=scroll.set)
		self.editor.pack(side='left', fill='both', expand=True)
		scroll.configure(command=self.editor.yview)
		self._load_editor(self.path_var.get())
		return page

	def _load_editor(self, path):
		self.editor.delete('1.0', 'end')
		path = norm_path(path)
		if path and os.path.isfile(path):
			self.editor.insert('1.0', read_text(path))

	def show_page(self, name):
		self.page = name
		self.page_general.pack_forget()
		self.page_voice.pack_forget()
		self.page_text.pack_forget()
		pages = {'常规': self.page_general, '语音': self.page_voice, '自定义文本': self.page_text}
		pages.get(name, self.page_general).pack(fill='both', expand=True)
		for item, btn in self.nav.items():
			if item == name:
				btn.configure(relief='sunken', bg='#d9ead3')
			else:
				btn.configure(relief='raised', bg='#f3f3f3')

	def browse(self):
		current = norm_path(self.path_var.get())
		initialdir = os.path.dirname(current) if current else SCRIPT_DIR
		if not os.path.isdir(initialdir):
			initialdir = SCRIPT_DIR
		initialfile = os.path.basename(current) if current else 'read.txt'
		options = {
			'parent': self,
			'title': '指定自定义文本位置',
			'initialdir': initialdir,
			'initialfile': initialfile,
			'defaultextension': '.txt',
			'filetypes': [('文本文件', '*.txt'), ('所有文件', '*.*')],
		}
		try:
			chosen = filedialog.asksaveasfilename(confirmoverwrite=False, **options)
		except tk.TclError:
			chosen = filedialog.asksaveasfilename(**options)
		if not chosen:
			return
		self.path_var.set(chosen)
		self._load_editor(chosen)
		self.message.configure(text='')

	def collect(self):
		raw = self.look_var.get().strip()
		if not raw.isdigit() or not (1 <= int(raw) <= 120):
			raise ValueError('看字时间请填 1 到 120 的整数')
		text_path = norm_path(self.path_var.get())
		content = self.editor.get('1.0', 'end-1c')
		if content.strip() and not text_path:
			raise ValueError('写了自定义文本，就要指定文本位置')
		voice = self._draft_voice_settings()
		return {
			'look_seconds': int(raw),
			'auto_speak': voice['auto_speak'],
			'start_deck': self.start_var.get(),
			'text_path': text_path,
			'book_ids': list(self.app.settings.get('book_ids') or []),
			'speak_rate_zh': voice['speak_rate_zh'],
			'speak_rate_en': voice['speak_rate_en'],
			'speak_pause': voice['speak_pause'],
			'voice_zh': voice['voice_zh'],
			'voice_en': voice['voice_en'],
			'show_ipa': voice['show_ipa'],
		}, content

	def save(self):
		try:
			settings, content = self.collect()
			old_path = norm_path(self.app.settings.get('text_path'))
			old_items = custom_items(read_text(old_path)) if old_path and os.path.isfile(old_path) else []
			new_items = custom_items(content) if settings['text_path'] else []
			text_changed = settings['text_path'] != old_path or new_items != old_items
			if settings['text_path']:
				write_custom_text(settings['text_path'], content)
			elif old_path and os.path.isfile(old_path) and not content.strip():
				write_custom_text(old_path, '')
				text_changed = True
			save_settings(settings)
		except (OSError, ValueError) as exc:
			self.message.configure(text=str(exc))
			return
		self.destroy()
		self.app.apply_settings(settings, text_changed)

	def cancel(self, _event=None):
		self.destroy()
		self.app.resume_after_settings()
		return 'break'


def main():
	maybe_update()
	if not IS_WIN:
		threading.Thread(target=_warm_melo, daemon=True).start()
	settings = load_settings()
	decks, _glosses = load_decks(settings)
	if not any(items for _name, items in decks):
		root = tk.Tk()
		root.withdraw()
		messagebox.showinfo('年年识字', '没有可认的内容。请先勾选生字来源或写自定义文本。')
		root.destroy()
		return
	LearnApp(settings).mainloop()


if __name__ == '__main__':
	main()
