#!/usr/bin/env python3

"""Simple desktop pet runner with sprite/sequence rendering.

Auto renderer preference:
  1. source sequence frames (if available)
  2. bibi reference renderers
"""

from __future__ import annotations

import argparse
import atexit
import configparser
import re
import json
import os
import random
import math
import sys
import subprocess
import threading
import time
import tkinter as tk
import uuid
from dataclasses import dataclass
from collections import Counter
from pathlib import Path
from typing import List, Optional, Tuple


_COMMAND_MAX_AGE_SECONDS = 300.0
_BLINK_VISUAL_TRANSITION_SECONDS = 0.0
_DEFAULT_VISUAL_TRANSITION_SECONDS = 0.20
_FOUNDATION_POSE_VISUAL_TRANSITION_SECONDS = 0.14
_LARGE_POSE_VISUAL_TRANSITION_SECONDS = 0.26
_POSE_ENTRY_TRANSITION_SECONDS = 0.12
_POSE_EXIT_TRANSITION_SECONDS = 0.14
_POSE_EXIT_START_SECONDS = {"listen": 1.32, "stretch": 1.86, "typing": 1.48, "wave": 1.52}
_LARGE_POSE_ACTIONS = {"edge_peek", "listen", "mouse_watch", "sleep", "stretch", "typing", "wake_soft", "wave"}
_FOUNDATION_POSE_ACTIONS = {"rest", "look", "breathe", "glance", "nod", "shake", "curious"}
APP_NAME = "只属于你"


def _visual_transition_seconds(prev_action: str | None, next_action: str | None) -> float:
    actions = {str(prev_action or ""), str(next_action or "")}
    if not next_action or prev_action == next_action:
        return 0.0
    if "drag" in actions:
        return 0.0
    if "blink" in actions:
        return _BLINK_VISUAL_TRANSITION_SECONDS
    if actions & _LARGE_POSE_ACTIONS:
        return _LARGE_POSE_VISUAL_TRANSITION_SECONDS
    if actions <= _FOUNDATION_POSE_ACTIONS:
        return _FOUNDATION_POSE_VISUAL_TRANSITION_SECONDS
    return _DEFAULT_VISUAL_TRANSITION_SECONDS


def _use_masked_visual_transition(prev_action: str | None, next_action: str | None) -> bool:
    actions = {str(prev_action or ""), str(next_action or "")}
    if "blink" in actions or "drag" in actions:
        return False
    return True


def _use_pose_switch_transition(prev_action: str | None, next_action: str | None) -> bool:
    actions = {str(prev_action or ""), str(next_action or "")}
    if "blink" in actions or "drag" in actions:
        return False
    return not actions <= _FOUNDATION_POSE_ACTIONS


def _shift_rgba_canvas(img, offset_y: int):
    from PIL import Image as _PILImage

    rgba = img.convert("RGBA")
    if not offset_y:
        return rgba
    canvas = _PILImage.new("RGBA", rgba.size, (0, 0, 0, 0))
    canvas.alpha_composite(rgba, (0, int(offset_y)))
    return canvas


def _pose_switch_transition_images(
    prev_img,
    next_img,
    progress: float,
    prev_action: str | None = None,
    next_action: str | None = None,
):
    progress = max(0.0, min(1.0, float(progress)))
    selected = prev_img if progress < 0.5 else next_img
    arc = math.sin(math.pi * progress)
    distance = 1
    direction = 1 if "sleep" in {str(prev_action or ""), str(next_action or "")} else -1
    return _shift_rgba_canvas(selected, int(round(direction * distance * arc)))


def _blend_transition_images(prev_img, next_img, alpha: float, masked: bool):
    from PIL import Image as _PILImage

    prev_rgba = prev_img.convert("RGBA")
    next_rgba = next_img.convert("RGBA")
    alpha = max(0.0, min(1.0, float(alpha)))
    if alpha <= 0.0:
        return prev_rgba
    if alpha >= 1.0:
        return next_rgba
    if not masked:
        return _remove_dark_alpha_fringe(_PILImage.blend(prev_rgba, next_rgba, alpha))

    from PIL import ImageChops as _ImageChops, ImageFilter as _ImageFilter

    diff = _ImageChops.difference(prev_rgba, next_rgba).convert("L")
    mask = diff.point(lambda p: 0 if p < 8 else min(255, int((p - 8) * 3.2)))
    mask = mask.filter(_ImageFilter.GaussianBlur(1.1))
    mask = mask.point(lambda p: int(max(0, min(255, p)) * alpha))
    out = _PILImage.composite(next_rgba, prev_rgba, mask)

    prev_alpha = prev_rgba.getchannel("A")
    next_alpha = next_rgba.getchannel("A")
    prev_present = prev_alpha.point(lambda p: 255 if p >= 24 else 0)
    next_present = next_alpha.point(lambda p: 255 if p >= 24 else 0)
    prev_only = _ImageChops.subtract(prev_present, next_present)
    next_only = _ImageChops.subtract(next_present, prev_present)
    transparent = _PILImage.new("RGBA", prev_rgba.size, (0, 0, 0, 0))
    if alpha < 0.5:
        out = _PILImage.composite(prev_rgba, out, prev_only)
        out = _PILImage.composite(transparent, out, next_only)
    else:
        out = _PILImage.composite(transparent, out, prev_only)
        out = _PILImage.composite(next_rgba, out, next_only)
    return _remove_dark_alpha_fringe(out)


def _ensure_vendor_pillow() -> bool:
    # Remove _vendor from sys.path (broken bundled PIL)
    vendor_root = Path(__file__).resolve().parent / "_vendor"
    if str(vendor_root) in sys.path:
        sys.path.remove(str(vendor_root))
    # Also ensure we don't import from current dir's _vendor
    cwd_vendor = Path.cwd() / "_vendor"
    if str(cwd_vendor) in sys.path:
        sys.path.remove(str(cwd_vendor))
    try:
        from PIL import Image, ImageOps, ImageTk
        return True
    except ModuleNotFoundError:
        return False


def _image_path(*parts: str) -> str:
    root = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
    return str(root.joinpath(*parts))


def _state_root() -> Path:
    if sys.platform == "darwin":
        root = Path.home() / "Library" / "Application Support" / "桌宠"
        try:
            root.mkdir(parents=True, exist_ok=True)
        except Exception:
            pass
        return root
    if sys.platform.startswith("win") and getattr(sys, "frozen", False):
        appdata = os.getenv("APPDATA", "").strip()
        root = Path(appdata) / "桌宠" if appdata else Path.home() / "AppData" / "Roaming" / "桌宠"
        try:
            root.mkdir(parents=True, exist_ok=True)
        except Exception:
            pass
        return root
    return Path(__file__).resolve().parent


def _config_path() -> str:
    if getattr(sys, "frozen", False):
        return str(_state_root() / "pet_config.ini")
    return _image_path("pet_config.ini")


def _state_path(*parts: str) -> str:
    return str(_state_root().joinpath(*parts))


def _pid_file() -> Path:
    return Path(_state_path("pet_process.pid"))


def _command_file() -> Path:
    return Path(_state_path("pet_command.json"))


def _status_file() -> Path:
    return Path(_state_path("pet_status.json"))


def _read_pet_command(path: Path) -> dict | None:
    try:
        if not path.exists():
            return None
        file_mtime = path.stat().st_mtime
        data = json.loads(path.read_text(encoding="utf-8-sig"))
        path.unlink()
        if not isinstance(data, dict):
            return None
        command_time = data.get("created_at", data.get("ts", file_mtime))
        try:
            command_time = float(command_time)
        except Exception:
            command_time = file_mtime
        if time.time() - command_time > _COMMAND_MAX_AGE_SECONDS:
            print("[COMMAND] stale command discarded")
            return None
        return data
    except Exception as exc:
        print(f"[COMMAND] read failed: {exc}")
        try:
            path.unlink()
        except Exception:
            pass
        return None


def _write_pid_file() -> None:
    try:
        path = _pid_file()
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(str(os.getpid()), encoding="utf-8")
    except Exception as exc:
        print(f"[PID] write failed: {exc}")


def _remove_pid_file() -> None:
    try:
        path = _pid_file()
        if path.exists() and path.read_text(encoding="utf-8").strip() == str(os.getpid()):
            path.unlink()
    except Exception:
        pass


def _gui_python_executable() -> str:
    exe = Path(sys.executable)
    if sys.platform.startswith("win") and exe.name.lower() == "python.exe":
        pythonw = exe.with_name("pythonw.exe")
        if pythonw.exists():
            return str(pythonw)
    return str(exe)


def _open_control_panel() -> None:
    if getattr(sys, "frozen", False):
        subprocess.Popen([sys.executable, "--control-panel"])
        return
    root = Path(_image_path())
    if sys.platform.startswith("win"):
        vbs = root / "只属于你控制面板.vbs"
        if vbs.exists():
            subprocess.Popen(["wscript.exe", str(vbs)], cwd=str(root))
            return
        bat = root / "control_panel.bat"
        if bat.exists():
            flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
            subprocess.Popen(["cmd.exe", "/c", str(bat)], cwd=str(root), creationflags=flags)
            return
    if sys.platform == "darwin":
        command = root / "只属于你控制面板.command"
        if command.exists():
            subprocess.Popen(["/bin/bash", str(command)], cwd=str(root))
            return
    panel = root / "control_panel.py"
    if not panel.exists():
        raise FileNotFoundError(f"control_panel.py not found: {panel}")
    subprocess.Popen([_gui_python_executable(), str(panel)], cwd=str(root))


def _crop_non_empty_area(img):
    bbox = img.getbbox()
    if not bbox:
        return img
    return img.crop(bbox)


def _remove_soft_alpha(img, min_alpha: int = 16, keep_soft: bool | None = None):
    """Clean alpha edges so Tk's color-key background does not bleed through."""
    if img.mode != "RGBA":
        return img
    from PIL import Image as _PILImage

    r, g, b, a = img.split()
    if keep_soft is None:
        keep_soft = _to_bool(os.getenv("PET_KEEP_SOFT_ALPHA"), False)
    if keep_soft:
        a = a.point(lambda p: 0 if p < min_alpha else p)
    else:
        a = a.point(lambda p: 0 if p < 80 else 255)
    return _PILImage.merge("RGBA", (r, g, b, a))


def _remove_dark_alpha_fringe(img):
    """Remove dark semi-transparent pixels that show up as black halos."""
    if img.mode != "RGBA":
        return img
    out = img.copy()
    pix = out.load()
    width, height = out.size
    changed = False
    for y in range(height):
        for x in range(width):
            r, g, b, a = pix[x, y]
            if 0 < a < 255:
                lum = (r + g + b) / 3.0
                if a < 22 or (lum < 42 and a < 190) or (lum < 80 and a < 110):
                    pix[x, y] = (r, g, b, 0)
                    changed = True
    return out if changed else img


def _parse_hex_rgb(value: str, fallback: tuple[int, int, int] = (1, 2, 3)) -> tuple[int, int, int]:
    text = str(value or "").strip()
    if text.startswith("#") and len(text) == 7:
        try:
            return int(text[1:3], 16), int(text[3:5], 16), int(text[5:7], 16)
        except ValueError:
            pass
    return fallback


def _tk_chroma_key_image(img, chroma: str = "#010203", alpha_threshold: int = 145):
    """Convert soft RGBA to hard chroma-key pixels for Tk transparentcolor windows."""
    from PIL import Image as _PILImage

    if img.mode != "RGBA":
        img = img.convert("RGBA")
    threshold = max(1, min(254, int(alpha_threshold)))
    chroma_rgb = _parse_hex_rgb(chroma)
    alpha = img.getchannel("A").point(lambda p: 255 if p >= threshold else 0)
    visible = img.convert("RGBA")
    visible.putalpha(255)
    out = _PILImage.new("RGBA", img.size, (*chroma_rgb, 255))
    out.paste(visible, (0, 0), alpha)
    return out


def _print_with_utf8(msg: str):
    try:
        print(msg)
    except UnicodeEncodeError:
        print(msg.encode("utf-8", errors="replace").decode("utf-8", errors="replace"))


def _debug_log(enabled: bool, msg: str) -> None:
    if enabled:
        _print_with_utf8(f"[DEBUG] {msg}")


def _to_bool(value: str | None, default: bool = False) -> bool:
    if value is None:
        return default
    v = str(value).strip().lower()
    if v in {"1", "true", "yes", "on", "y"}:
        return True
    if v in {"0", "false", "no", "off", "n"}:
        return False
    return default


def _opacity_from_config(cfg: configparser.ConfigParser, fallback: float = 0.98) -> float:
    raw = cfg.get("DEFAULT", "PET_OPACITY", fallback=str(fallback)).strip()
    try:
        value = float(raw)
    except (TypeError, ValueError):
        value = fallback
    if value > 1:
        value = value / 100.0
    return max(0.35, min(value, 1.0))


def _activity_mode_from_config(cfg: configparser.ConfigParser) -> str:
    value = cfg.get("DEFAULT", "PET_ACTIVITY_MODE", fallback="normal").strip().lower()
    aliases = {
        "quiet": "quiet",
        "calm": "quiet",
        "silent": "quiet",
        "安静": "quiet",
        "normal": "normal",
        "companion": "normal",
        "陪伴": "normal",
        "正常": "normal",
        "lively": "lively",
        "active": "lively",
        "活泼": "lively",
    }
    return aliases.get(value, "normal")


def _motion_detail_from_config(cfg: configparser.ConfigParser) -> str:
    value = cfg.get("DEFAULT", "PET_MOTION_DETAIL", fallback="stable").strip().lower()
    aliases = {
        "stable": "stable",
        "calm": "stable",
        "soft": "stable",
        "稳定": "stable",
        "natural": "natural",
        "normal": "natural",
        "smooth": "natural",
        "自然": "natural",
        "expressive": "expressive",
        "lively": "expressive",
        "active": "expressive",
        "明显": "expressive",
    }
    return aliases.get(value, "stable")


def _idle_breathe_from_config(cfg: configparser.ConfigParser) -> bool:
    return _to_bool(cfg.get("DEFAULT", "PET_IDLE_BREATHE", fallback="false"), False)


def _reply_style_from_config(cfg: configparser.ConfigParser) -> str:
    value = cfg.get("DEFAULT", "REPLY_STYLE", fallback="short").strip().lower()
    aliases = {
        "warm": "warm",
        "gentle": "warm",
        "温柔": "warm",
        "lively": "lively",
        "active": "lively",
        "活泼": "lively",
        "short": "short",
        "brief": "short",
        "简短": "short",
        "clingy": "clingy",
        "cute": "clingy",
        "黏人": "clingy",
        "useful": "useful",
        "practical": "useful",
        "实用": "useful",
    }
    return aliases.get(value, "short")


def _reply_length_from_config(cfg: configparser.ConfigParser) -> str:
    value = cfg.get("DEFAULT", "REPLY_LENGTH", fallback="short").strip().lower()
    aliases = {
        "short": "short",
        "very_short": "short",
        "很短": "short",
        "normal": "normal",
        "medium": "normal",
        "正常": "normal",
        "long": "long",
        "more": "long",
        "多说一点": "long",
    }
    return aliases.get(value, "short")


def _reply_instruction(style: str, length: str) -> str:
    style_lines = {
        "warm": "回复风格：温柔、亲近、有陪伴感，像真实对象在认真回应。",
        "lively": "回复风格：轻快活泼，可以有一点俏皮，但不要吵。",
        "short": "回复风格：干净简短，少废话，像聊天软件里自然回一句。",
        "clingy": "回复风格：稍微黏人、会撒娇、会表达在意，但不要油腻。",
        "useful": "回复风格：先有用、直接解决问题，再保留一点温柔陪伴感。",
    }
    length_lines = {
        "short": "回复长度：尽量 1 句，最多 2 句。",
        "normal": "回复长度：1 到 3 句，短一点，自然一点。",
        "long": "回复长度：可以多说一点，但仍保持聊天感，不写长篇说明。",
    }
    return "\n".join(
        [
            style_lines.get(style, style_lines["warm"]),
            length_lines.get(length, length_lines["normal"]),
            "不要每次都用同一种句式；不要机械重复语气样本；不要说你在执行某种模式。",
            "像微信真人一样直接说话，不要用括号、星号描写动作或表情，不要写舞台旁白。",
            "一条消息里有多个重点时，优先照顾情绪，再简短回应其他重点；不要只抓住一个关键词。",
            "用户明确表达顾虑或问了一件事时，必须直接回答；例如担心打扰或吵醒，就明确让她放心。",
            "不要假装自己能在现实中替用户吹头发、递东西或完成其他身体动作；给能真正做到的关心。",
            "不要连续追问；确实需要确认时最多问一个简短问题。",
            "少用连续省略号和波浪号，不要为了显得亲密而堆很多语气词。",
        ]
    )


def _tokens_for_reply_length(length: str) -> int:
    if length == "short":
        return 80
    if length == "long":
        return 180
    return 120


def _idle_action_for_mode(mode: str, detail: str = "stable") -> tuple[str, float]:
    r = random.random()
    detail = detail if detail in {"stable", "natural", "expressive"} else "stable"
    if mode == "quiet":
        if r < 0.92:
            return "rest", random.uniform(34.0, 70.0)
        return "sleep", random.uniform(36.0, 70.0)
    if mode == "lively":
        if detail == "stable":
            if r < 0.84:
                return "rest", random.uniform(18.0, 34.0)
            if r < 0.93:
                return "happy", random.uniform(1.8, 2.8)
            return "sleep", random.uniform(24.0, 42.0)
        if detail == "expressive":
            if r < 0.68:
                return "rest", random.uniform(10.0, 20.0)
            if r < 0.86:
                return "happy", random.uniform(1.8, 2.8)
            return "sleep", random.uniform(20.0, 36.0)
        if r < 0.78:
            return "rest", random.uniform(14.0, 28.0)
        if r < 0.90:
            return "happy", random.uniform(2.0, 3.2)
        return "sleep", random.uniform(22.0, 38.0)
    if detail == "expressive":
        if r < 0.86:
            return "rest", random.uniform(16.0, 32.0)
        if r < 0.95:
            return "happy", random.uniform(1.8, 2.8)
        return "sleep", random.uniform(28.0, 50.0)
    if detail == "natural":
        if r < 0.94:
            return "rest", random.uniform(26.0, 54.0)
        return "sleep", random.uniform(30.0, 56.0)
    if r < 0.99:
        return "rest", random.uniform(42.0, 92.0)
    return "sleep", random.uniform(38.0, 70.0)


def _motion_duration_scale(detail: str) -> float:
    if detail == "expressive":
        return 0.95
    if detail == "natural":
        return 1.25
    return 1.85


def _motion_cooldown_scale(detail: str) -> float:
    if detail == "expressive":
        return 0.75
    if detail == "natural":
        return 1.0
    return 2.4


def _movement_profile(mode: str, detail: str = "stable") -> tuple[float, float, float, float]:
    return 0.0, 0.0, 0.0, 0.0


_MAJOR_IDLE_ACTIONS = {"sleep"}
_TEMP_LIVELY_SECONDS = 120.0
_RANDOM_COMPANION_SEQUENCES = (
    "ack",
    "calm_breathe",
    "ack",
    "calm_breathe",
)


def _choose_random_companion_sequence(sequences: dict) -> str:
    available = [name for name in _RANDOM_COMPANION_SEQUENCES if name in sequences]
    if available:
        return random.choice(available)
    fallback = [name for name in sequences if name not in {"patrol", "not_that", "goodnight"}]
    return random.choice(fallback or list(sequences))


def _head_pat_steps() -> list[tuple[str, float]]:
    return [("breathe", 1.0), ("happy", 1.6), ("rest", 2.2)]


def _manual_action_sequence_specs() -> dict[str, dict[str, object]]:
    return {
        "wave": {
            "label": "挥挥手。",
            "steps": [("breathe", 0.9), ("happy", 0.9), ("wave", 2.3), ("happy", 0.9), ("rest", 1.8)],
        },
        "sleep": {
            "label": "困一下。",
            "steps": [("breathe", 1.5), ("sleep", 5.0), ("wake_soft", 0.8), ("rest", 2.0)],
        },
        "happy": {
            "label": "开心一下。",
            "steps": [("breathe", 0.8), ("happy", 2.2), ("breathe", 1.2), ("rest", 1.6)],
        },
    }


def _manual_action_sequence_for_action(pet_action: str) -> dict[str, object] | None:
    return _manual_action_sequence_specs().get(str(pet_action or "").strip())


def _command_sequence_specs() -> dict[str, dict[str, object]]:
    return {
        "greet": {
            "label": "我在呀。",
            "steps": [("breathe", 0.9), ("happy", 2.0), ("breathe", 1.0), ("rest", 1.6)],
        },
        "ack": {
            "label": "收到啦。",
            "steps": [("breathe", 1.4), ("blink", 0.7), ("rest", 2.2)],
        },
        "head_pat": {
            "label": "摸摸头收到。",
            "steps": _head_pat_steps(),
        },
        "calm_breathe": {
            "label": "慢慢呼吸。",
            "steps": [("breathe", 7.2), ("rest", 2.8)],
        },
        "goodnight": {
            "label": "晚安，早点休息。",
            "steps": [("breathe", 2.0), ("sleep", 4.0), ("rest", 2.4)],
        },
        "patrol": {
            "label": "我把动作都走一遍。",
            "steps": [
                ("breathe", 2.4, "轻呼吸"),
                ("blink", 0.6, "眨眼"),
                ("nod", 1.8, "点头"),
                ("shake", 1.8, "摇头"),
                ("curious", 2.2, "歪头看看"),
                ("listen", 2.2, "认真听"),
                ("happy", 1.8, "开心"),
                ("shy", 2.0, "害羞"),
                ("wave", 2.4, "挥手"),
                ("stretch", 2.6, "伸懒腰"),
                ("walk", 2.8, "走两步"),
                ("sleep", 3.5, "睡觉"),
                ("wake_soft", 0.8, "醒来"),
                ("rest", 2.5, "回到站姿"),
            ],
        },
    }


def _resolve_command_sequence(sequence_name: str) -> dict[str, object] | None:
    sequence_name = sequence_name.strip().lower()
    sequences = _command_sequence_specs()
    if sequence_name in {"showcase", "check", "all", "巡检", "动作巡检"}:
        sequence_name = "patrol"
    if sequence_name in {"random", "surprise"}:
        sequence_name = _choose_random_companion_sequence(sequences)
    return sequences.get(sequence_name)


def _sequence_steps(spec: dict[str, object]) -> list[tuple]:
    steps = []
    for raw_step in spec.get("steps", []):
        action = str(raw_step[0])
        seconds = float(raw_step[1])
        if len(raw_step) >= 3:
            steps.append((action, seconds, str(raw_step[2])))
        else:
            steps.append((action, seconds))
    return steps


_REPLY_MOTION_CASES = (
    ("neutral", "普通回复"),
    ("question", "问题回复"),
    ("thanks", "感谢回复"),
    ("ack", "确认回复"),
    ("praise", "夸奖回复"),
    ("affection", "亲近回复"),
    ("happy", "开心回复"),
    ("comfort", "安慰回复"),
    ("help", "帮忙回复"),
    ("correct", "纠正回复"),
    ("night", "晚安回复"),
)


def _reply_action_for_reaction_name(name: str) -> str:
    if name in {"praise", "affection"}:
        return "happy"
    if name == "happy":
        return "happy"
    if name in {"thanks", "ack"}:
        return "happy"
    if name == "question":
        return "look"
    return "look"


def _reply_motion_steps_for_reaction(reaction_name: str, text_len: int = 48) -> list[tuple[str, float]]:
    reply_action = _reply_action_for_reaction_name(reaction_name)
    talk_seconds = max(0.85, min(1.45, 0.65 + text_len * 0.011))
    reply_seconds = max(1.1, min(1.9, 1.0 + text_len * 0.014))
    steps: list[tuple[str, float]] = [("look", 0.7), ("talk", talk_seconds)]
    if reply_action == "look":
        steps.extend([("look", 1.4), ("rest", 1.8)])
    elif reply_action == "happy":
        steps.extend([("look", 0.7), ("breathe", 1.0), ("happy", reply_seconds), ("rest", 1.5)])
    else:
        steps.extend([("look", 0.7), (reply_action, reply_seconds), ("rest", 1.8)])
    return steps


def _reply_motion_specs(text_len: int = 48) -> dict[str, dict[str, object]]:
    return {
        name: {
            "label": label,
            "steps": _reply_motion_steps_for_reaction(name, text_len),
        }
        for name, label in _REPLY_MOTION_CASES
    }


def _reply_motion_label(reaction_name: str) -> str:
    spec = _reply_motion_specs().get(str(reaction_name or "neutral"))
    return str((spec or {}).get("label", "普通回复"))


_WAIT_MOTION_CASES = (
    ("neutral", "普通等待"),
    ("question", "问题等待"),
    ("thanks", "感谢等待"),
    ("ack", "确认等待"),
    ("praise", "夸奖等待"),
    ("affection", "亲近等待"),
    ("happy", "开心等待"),
    ("comfort", "安慰等待"),
    ("help", "帮忙等待"),
    ("correct", "纠正等待"),
    ("night", "晚安等待"),
)


def _wait_motion_steps_for_reaction(reaction_name: str) -> list[tuple[str, float]]:
    name = str(reaction_name or "neutral")
    if name == "question":
        return [("look", 0.7), ("breathe", 4.4), ("rest", 2.0)]
    if name in {"praise", "affection"}:
        return [("look", 0.7), ("breathe", 1.0), ("happy", 1.35), ("breathe", 2.8), ("rest", 2.0)]
    if name in {"ack", "thanks"}:
        return [("look", 0.7), ("breathe", 4.0), ("rest", 2.0)]
    if name == "happy":
        return [("look", 0.7), ("breathe", 1.0), ("happy", 1.35), ("breathe", 2.8), ("rest", 2.0)]
    return [("look", 0.7), ("breathe", 4.8), ("rest", 2.0)]


def _wait_action_for_reaction_name(reaction_name: str, elapsed: float) -> str:
    steps = _wait_motion_steps_for_reaction(reaction_name)
    cursor = 0.0
    for action, seconds in steps:
        cursor += max(0.01, float(seconds))
        if elapsed < cursor:
            return action
    return steps[-1][0] if steps else "look"


def _wait_motion_specs() -> dict[str, dict[str, object]]:
    return {
        name: {
            "label": label,
            "steps": _wait_motion_steps_for_reaction(name),
        }
        for name, label in _WAIT_MOTION_CASES
    }


def _wait_motion_label(reaction_name: str) -> str:
    spec = _wait_motion_specs().get(str(reaction_name or "neutral"))
    return str((spec or {}).get("label", "普通等待"))


def _auto_chat_bounds(mode: str, interval_min: int, interval_max: int) -> tuple[int, int]:
    floors = {
        "quiet": (900, 1800),
        "normal": (420, 900),
        "lively": (180, 420),
    }
    min_floor, max_floor = floors.get(mode, floors["normal"])
    interval_min = max(interval_min, min_floor)
    interval_max = max(interval_max, max_floor, interval_min + 60)
    return interval_min, interval_max


def _chat_duration_ms(text: str, requested_ms: int) -> int:
    """Keep short bubbles snappy, but give long replies enough reading time."""
    requested_ms = max(3600, int(requested_ms))
    compact_len = len("".join(str(text).split()))
    if compact_len <= 24:
        return requested_ms
    extra_lines = max(0, str(text).count("\n"))
    suggested = 3600 + int((compact_len - 24) * 90) + extra_lines * 450
    return max(requested_ms, min(12000, suggested))


_STARTUP_GREETING_COOLDOWN_SECONDS = 30 * 60


def _startup_greeting_due() -> bool:
    path = Path(_state_path("pet_startup_seen.txt"))
    try:
        last_seen = float(path.read_text(encoding="utf-8").strip())
        return time.time() - last_seen >= _STARTUP_GREETING_COOLDOWN_SECONDS
    except Exception:
        return True


def _mark_startup_greeting_seen() -> None:
    path = Path(_state_path("pet_startup_seen.txt"))
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(str(time.time()), encoding="utf-8")
    except Exception:
        pass


def _resize_height(img, target_h: int) -> "Image.Image":
    from PIL import Image as _PILImage

    w, h = img.size
    if h == 0:
        return img
    ratio = target_h / float(h)
    target_w = max(1, int(round(w * ratio)))
    return _remove_dark_alpha_fringe(img.resize((target_w, target_h), _PILImage.Resampling.LANCZOS))


def _normalize_path(value: str | None) -> str | None:
    if not value:
        return None
    path_text = value.strip().strip('"').strip("'")
    if not path_text:
        return None
    p = Path(path_text)
    if not p.is_absolute():
        p = Path(_image_path(path_text))
    p = p.expanduser()
    if not p.exists():
        return None
    try:
        return str(p.resolve())
    except Exception:
        return str(p)


def _build_stage(img_w: int, img_h: int) -> Tuple[int, int]:
    """Build a stage that fits the image tightly, with minimal vertical headroom
    for animation effects (bobbing/swing). No forced aspect ratio."""
    # Only add vertical headroom for animations
    stage_h = int(img_h * 1.12)
    stage_w = max(72, img_w + 8)  # small horizontal breathing room
    return stage_w, stage_h


@dataclass
class RendererFrame:
    image: "Image.Image"
    offset_y: int = 0
    offset_x: int = 0


class ChatHistory:
    """Parse WeChat-style chat exports and keep light, searchable snippets."""

    def __init__(
        self,
        source_path: str | None = None,
        *,
        self_name: str = "",
        partner_name: str = "",
    ):
        self.self_name = (self_name or "").strip()
        self.partner_name = (partner_name or "").strip()
        self.messages: list[tuple[str, str]] = []
        self.partner_lines: list[str] = []
        self._ready = False
        self.total_lines = 0
        self.parsed_lines = 0
        self.failed_lines = 0
        self.source_paths: list[str] = []
        self.source_count = 0
        if source_path:
            self.load(source_path)

    def _infer_speaker_names(self, names: list[str]) -> None:
        if not names:
            return

        name_counter = Counter(names)

        if not self.self_name:
            # First-person/common speaker cues in WeChat exports.
            first_person_candidates = (
                "我",
                "俺",
                "本人",
                "Me",
                "me",
                "I",
                "i",
                "我的",
            )
            for candidate in first_person_candidates:
                if candidate in name_counter:
                    self.self_name = candidate
                    break

        if not self.self_name:
            # Heuristic fallback: person who has more utterances is usually self.
            self.self_name = name_counter.most_common(1)[0][0]

        if self.partner_name and self.partner_name == self.self_name:
            self.partner_name = ""

        if not self.partner_name:
            candidates = [
                (name, cnt)
                for name, cnt in name_counter.items()
                if name != self.self_name
            ]
            if candidates:
                candidates.sort(key=lambda item: (-item[1], item[0]))
                self.partner_name = candidates[0][0]
            else:
                self.partner_name = "?"

        # prefer explicit partner over automatic guess if guess is blank/unknown
        if self.partner_name == "??":
            self.partner_name = "?"

    def load(self, source_path: str) -> bool:
        """Load a chat log. Supports .txt, .json, .html, and export directories / zip files."""
        import tempfile
        import zipfile

        path = Path(source_path)
        if not path.exists():
            raise FileNotFoundError(f"聊天记录文件不存在: {source_path}")

        # Directory export from WeChat: parse all supported text/json/html files.
        if path.is_dir():
            files = [
                p
                for p in sorted(path.rglob("*"))
                if p.is_file() and p.suffix.lower() in {".txt", ".json", ".html", ".htm"}
            ]
            self.source_paths = [str(p) for p in files]
            self.source_count = len(files)
            return self._load_from_files(files)

        # Zip export: extract and parse all supported files.
        if path.suffix.lower() == ".zip":
            with tempfile.TemporaryDirectory() as tmp_dir:
                with zipfile.ZipFile(path, "r") as zf:
                    zf.extractall(tmp_dir)
                tmp_root = Path(tmp_dir)
                files = [
                    p
                    for p in sorted(tmp_root.rglob("*"))
                    if p.is_file() and p.suffix.lower() in {".txt", ".json", ".html", ".htm"}
                ]
                self.source_paths = [str(p) for p in files]
                self.source_count = len(files)
                return self._load_from_files(files)

        suffix = path.suffix.lower()
        self.source_paths = [str(path)]
        self.source_count = 1
        raw = self._read_text_file(path)
        lines = raw.splitlines()
        self.total_lines = len(lines)

        if suffix in {".json"}:
            parsed = self._parse_json(raw)
        elif suffix in {".html", ".htm"}:
            parsed = self._parse_html(raw)
        else:
            parsed = self._parse_text(raw)

        self.parsed_lines = len(parsed)
        self.failed_lines = max(0, self.total_lines - self.parsed_lines)
        filtered = self._cleanup_parsed_lines(parsed)

        self.messages = filtered
        if not self.messages:
            self._ready = False
            return False

        names = [name for name, _ in self.messages if name]
        self._infer_speaker_names(names)

        self.partner_lines = [
            msg for speaker, msg in self.messages if speaker == self.partner_name
        ]
        if not self.partner_lines:
            self.partner_lines = [msg for speaker, msg in self.messages if speaker != self.self_name]

        self._ready = True
        return True

    def _cleanup_parsed_lines(self, parsed: list[tuple[str, str]]) -> list[tuple[str, str]]:
        cleaned = []
        for speaker, msg in parsed:
            if not msg:
                continue

            msg = msg.strip()
            if not msg:
                continue

            if len(msg) > 220:
                # Keep snippets short for bubble/readability.
                msg = msg[:220].strip()

            # Strip duplicated speaker prefix if present.
            if speaker:
                prefix = f"{speaker}:"
                if msg.startswith(prefix):
                    msg = msg[len(prefix):].strip()
            elif msg.startswith(f"{self.self_name}:"):
                speaker = self.self_name
                msg = msg[len(f"{speaker}:") :].strip()
            elif self.partner_name and msg.startswith(f"{self.partner_name}:"):
                speaker = self.partner_name
                msg = msg[len(f"{speaker}:") :].strip()

            if not speaker:
                speaker = "??"
            if not msg:
                continue
            if self._is_system_line(msg):
                continue
            cleaned.append((speaker, msg))

        return cleaned

    def _load_from_files(self, files: list[Path]) -> bool:
        parsed: list[tuple[str, str]] = []
        if not files:
            self.total_lines = 0
            self.parsed_lines = 0
            self.failed_lines = 0
            self._ready = False
            self.source_count = 0
            self.source_paths = []
            return False

        for fp in files:
            suffix = fp.suffix.lower()
            raw = self._read_text_file(fp)
            try:
                if suffix == ".json":
                    parsed.extend(self._parse_json(raw))
                elif suffix in {".html", ".htm"}:
                    parsed.extend(self._parse_html(raw))
                else:
                    parsed.extend(self._parse_text(raw))
            except Exception:
                # keep best-effort parsing for mixed exports
                pass

        lines_total = 0
        for fp in files:
            try:
                lines_total += len(self._read_text_file(fp).splitlines())
            except Exception:
                pass
        self.total_lines = lines_total
        self.parsed_lines = len(parsed)
        self.failed_lines = max(0, self.total_lines - self.parsed_lines)

        filtered = self._cleanup_parsed_lines(parsed)
        self.messages = filtered
        if not self.messages:
            self._ready = False
            return False

        names = [name for name, _ in self.messages if name]
        self._infer_speaker_names(names)

        self.partner_lines = [msg for speaker, msg in self.messages if speaker == self.partner_name]
        if not self.partner_lines:
            self.partner_lines = [msg for speaker, msg in self.messages if speaker != self.self_name]

        self._ready = True
        return True

    def summary(self) -> str:
        if not self._ready:
            if self.total_lines == 0:
                return f"未识别到任何有效行（已扫描 {self.source_count} 个文件）。"
            return (
                f"已读取 {self.total_lines} 行，识别为 {self.parsed_lines} 条聊天（扫描 {self.source_count} 个文件），"
                f"未匹配 {self.failed_lines} 行。可能是导出格式不兼容。"
            )
        return (
            f"已读取 {self.total_lines} 行，识别到 {len(self.messages)} 条对话（扫描 {self.source_count} 个文件），"
            f"其中 {len(self.partner_lines)} 条可用于对话回复。"
        )

    @staticmethod
    def _read_text_file(path: Path) -> str:
        """Read export file in common encodings."""
        raw = path.read_bytes()
        for enc in ("utf-8-sig", "utf-8", "gb18030", "gbk", "latin-1"):
            try:
                return raw.decode(enc)
            except UnicodeDecodeError:
                continue
        return raw.decode("utf-8", errors="replace")

    @staticmethod
    def _normalize_line(raw_line: str) -> str:
        import html
        import unicodedata

        line = html.unescape(raw_line or "")
        # strip invisible control chars / zero width joiners from exports
        line = line.replace("\u200b", "")
        line = unicodedata.normalize("NFKC", line)
        line = line.strip().lstrip("\ufeff")

        # Strip time and wrapper prefixes that often exist in exports.
        line = re.sub(r"^\[[^\]]+\]\s*", "", line)
        for _ in range(2):
            line = re.sub(
                r"^\s*\d{4}[-/.]\d{1,2}[-/.]\d{1,2}[ T]\d{1,2}:\d{1,2}(?::\d{1,2})?(?:\.\d+)?\s*(?:->\s*)?",
                "",
                line,
            )
            line = re.sub(
                r"^\s*\d{1,2}[-/.]\d{1,2}\s+\d{1,2}:\d{1,2}(?::\d{1,2})?\s*(?:->\s*)?",
                "",
                line,
            )
            line = re.sub(r"^\s*\d{1,2}:\d{1,2}(?::\d{1,2})?\s*", "", line)
        line = re.sub(r"^<\s*(.+?)\s*>\s*", "", line)
        return line.strip()

    @staticmethod
    def _looks_like_timestamp(text: str) -> bool:
        if not text:
            return False
        patterns = (
            r"^\d{4}年\d{1,2}月\d{1,2}日\s+\d{1,2}:\d{2}(?::\d{2})?$",
            r"^\d{4}[-/.]\d{1,2}[-/.]\d{1,2}\s+\d{1,2}:\d{2}(?::\d{2})?$",
            r"^\d{1,2}[-/.]\d{1,2}\s+\d{1,2}:\d{2}(?::\d{2})?$",
        )
        return any(re.match(pattern, text) for pattern in patterns)

    @staticmethod
    def _is_system_line(text: str) -> bool:
        # Skip obvious non-chat system lines
        if not text:
            return True

        keys = (
            "[动画表情]",
            "动画表情",
            "[图片]",
            "[视频]",
            "[语音]",
            "[文件]",
            "撤回",
            "系统消息",
            "消息",
            "聊天记录",
            "deleted",
            "加入群聊",
            "你撤回了一条消息",
            "你撤回了消息",
            "移除群聊",
            "删除了",
            "System",
            "---",
        )
        if text.startswith("---") or text.startswith("==="):
            return True
        return any(k in text for k in keys)

    def _parse_text(self, text: str) -> list[tuple[str, str]]:
        """Parse common WeChat txt export styles."""
        out: list[tuple[str, str]] = []

        # WeChat copied/exported text often uses 3-line records:
        # speaker
        # 2026年06月01日 00:01
        # message
        compact_lines = [
            self._normalize_line(raw_line)
            for raw_line in text.splitlines()
            if raw_line and raw_line.strip()
        ]
        block_out: list[tuple[str, str]] = []
        i = 0
        while i + 2 < len(compact_lines):
            speaker = compact_lines[i]
            timestamp = compact_lines[i + 1]
            msg = compact_lines[i + 2]
            if (
                speaker
                and len(speaker) <= 40
                and not self._looks_like_timestamp(speaker)
                and self._looks_like_timestamp(timestamp)
                and msg
                and not self._looks_like_timestamp(msg)
                and not self._is_system_line(speaker)
                and not self._is_system_line(msg)
            ):
                block_out.append((speaker, msg))
                i += 3
                continue
            i += 1
        if len(block_out) >= 3:
            return block_out

        # Common patterns:
        # [time] Name: msg
        # 2026-01-01 09:00 Name: msg
        # Name: msg
        # <Name> msg
        line_re = re.compile(
            r"^(?:<(?P<angle>[^>]+)>\s*)?(?P<name>[^:：-]+?)\s*[:：-]\s*(?P<msg>.+)$"
        )
        arrow_re = re.compile(r"^(?P<name>[^:\-＞>》]+?)\s*(?:->|->\s*|＞>\s*|:\s*)(?P<msg>.+)$")
        fallback_re = re.compile(r"^(?P<name>.+?)\s*[:：]\s*(?P<msg>.+)$")
        dash_re = re.compile(r"^(?P<name>.+?)\s*[-\-—]\s*(?P<msg>.+)$")
        tab_re = re.compile(r"^(?P<name>[^:\t：＞>》]+?)\s*\t+\s*(?P<msg>.+)$")
        last_speaker: str | None = None
        merged = False

        for raw_line in text.splitlines():
            line = self._normalize_line(raw_line)
            if not line or self._is_system_line(line):
                continue

            m = line_re.match(line)
            if not m:
                m = arrow_re.match(line)
            if not m:
                m = fallback_re.match(line)
            if not m:
                m = dash_re.match(line)
            if not m:
                m = tab_re.match(line)

            # Broad fallback: any short prefix + colon/colon-like separator is a speaker marker.
            if not m and (":" in line or "：" in line):
                idx = line.find(":") if ":" in line else line.find("：")
                if 0 < idx < 40:
                    name = line[:idx].strip()
                    msg = line[idx + 1 :].strip()
                    if name and not self._is_system_line(name) and len(name) <= 40:
                        name = name.replace("<", "").replace(">", "").strip()
                        if msg and not self._is_system_line(msg):
                            out.append((name, msg))
                        merged = True
                        last_speaker = name
                        continue

            if not m:
                # multi-line continuation text: attach to previous speaker
                if merged and last_speaker and out:
                    out[-1] = (out[-1][0], f"{out[-1][1]} {line}")
                    merged = True
                else:
                    merged = False
                continue

            name = (m.group("name") or "").strip()
            msg = (m.group("msg") or "").strip()
            if not name and last_speaker:
                name = last_speaker
            if not name:
                continue

            msg = msg.replace("\u200b", "").strip()
            if not msg or self._is_system_line(msg):
                continue

            name = name.replace("<", "").replace(">", "").strip()
            if not name:
                continue
            last_speaker = name
            merged = True
            out.append((name, msg))

        return out

    def _parse_html(self, text: str) -> list[tuple[str, str]]:
        # Remove basic tags and parse as text.
        cleaned = re.sub(r"<[^>]+>", "\n", text)
        return self._parse_text(cleaned)

    def _parse_json(self, text: str) -> list[tuple[str, str]]:
        parsed: list[tuple[str, str]] = []
        try:
            data = json.loads(text)
        except Exception:
            return parsed

        def _walk(obj):
            if isinstance(obj, dict):
                speaker = str(
                    obj.get("name")
                    or obj.get("sender")
                    or obj.get("from")
                    or obj.get("fromUser")
                    or obj.get("nickName")
                    or obj.get("nickname")
                    or ""
                ).strip()
                msg = str(
                    obj.get("content")
                    or obj.get("text")
                    or obj.get("msg")
                    or obj.get("message")
                    or obj.get("messageContent")
                    or obj.get("strContent")
                    or ""
                ).strip()
                msg = self._normalize_line(msg)
                if msg and speaker:
                    parsed.append((speaker, msg))
                    return

                nested = obj.get("message")
                if isinstance(nested, dict):
                    nspeaker = str(
                        nested.get("name")
                        or nested.get("sender")
                        or nested.get("from")
                        or nested.get("nickName")
                        or ""
                    ).strip()
                    nmsg = self._normalize_line(
                        str(
                            nested.get("content")
                            or nested.get("text")
                            or nested.get("msg")
                            or nested.get("strContent")
                            or ""
                        )
                    )
                    if nspeaker and nmsg:
                        parsed.append((nspeaker, nmsg))

                # common old export format
                nspeaker2 = str(obj.get("strTalker", "")).strip()
                nmsg2 = self._normalize_line(str(obj.get("strContent", "")))
                if nspeaker2 and nmsg2:
                    parsed.append((nspeaker2, nmsg2))

                # some JSON exports place messages in nested list fields
                for key in ("messages", "chats", "list", "records", "msgs", "messageList", "data"):
                    if key in obj and isinstance(obj[key], list):
                        for x in obj[key]:
                            _walk(x)
                for v in obj.values():
                    if isinstance(v, (list, dict)):
                        _walk(v)
            elif isinstance(obj, list):
                for item in obj:
                    _walk(item)

        _walk(data)
        return parsed

    def suggest_reply(self, text: str) -> str | None:
        if not self._ready or not self.partner_lines:
            return None
        t = text.strip().lower()
        if not t:
            return None
        tokens = [
            tok
            for tok in re.findall(r"[A-Za-z0-9\u4e00-\u9fff]+", t)
            if len(tok) > 1
        ]
        candidates = []
        if tokens:
            for line in self.partner_lines:
                low = line.lower()
                if any(tok.lower() in low for tok in tokens):
                    candidates.append(line)
        if not candidates:
            candidates = self.partner_lines[:]
        return random.choice(candidates) if candidates else None

    def context_lines(self, limit: int = 12) -> list[str]:
        if not self._ready:
            return []
        picks = self.messages[-(limit * 2) :]
        if not picks:
            return []
        return [f"{speaker}: {msg}" for speaker, msg in picks[-limit:]]

    def style_lines(self, limit: int = 18) -> list[str]:
        """Return partner samples for AI style reference, not exact replay."""
        if not self._ready or not self.partner_lines:
            return []
        lines = [
            line.strip()
            for line in self.partner_lines
            if line and 2 <= len(line.strip()) <= 80 and not self._is_system_line(line)
        ]
        if not lines:
            return []
        return lines[-limit:]

    @property
    def enabled(self) -> bool:
        return self._ready


class ConversationMemory:
    """Small local memory store for the desktop pet."""

    _BUCKET_LIMITS = {"facts": 30, "preferences": 30, "notes": 40}

    def __init__(self, path: str | Path, recent_limit: int = 16):
        self.path = Path(path)
        self.recent_limit = max(4, int(recent_limit or 16))
        self.data = self._empty_data()
        self.load()

    @staticmethod
    def _empty_data() -> dict:
        return {
            "version": 3,
            "facts": [],
            "preferences": [],
            "notes": [],
            "recent_turns": [],
        }

    @staticmethod
    def _now_text() -> str:
        return time.strftime("%Y-%m-%d %H:%M:%S")

    @staticmethod
    def _time_text(timestamp: float) -> str:
        return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp))

    @staticmethod
    def _parse_time(text: str | None) -> float | None:
        if not text:
            return None
        try:
            return time.mktime(time.strptime(str(text), "%Y-%m-%d %H:%M:%S"))
        except (TypeError, ValueError, OverflowError):
            return None

    @staticmethod
    def _clean_text(text: str, limit: int = 120) -> str:
        text = re.sub(r"\s+", " ", (text or "").strip())
        return text[:limit].strip()

    @staticmethod
    def _looks_sensitive(text: str) -> bool:
        lowered = (text or "").lower()
        sensitive_keys = (
            "密码",
            "验证码",
            "身份证",
            "银行卡",
            "api",
            "token",
            "key",
            "密钥",
            "secret",
        )
        return any(key in lowered for key in sensitive_keys)

    @staticmethod
    def _topic_for(bucket: str, text: str) -> str:
        compact = re.sub(r"[\s，。,.!！？:：]", "", text or "").lower()
        if text.startswith("称呼用户为："):
            return "call_name"
        if bucket == "facts":
            if re.match(r"我叫", text):
                return "user_name"
            if re.match(r"我正在", text):
                return "current_activity"
            if re.match(r"我今天", text):
                return "today_status"
            if re.match(r"我明天", text):
                return "tomorrow_plan"
            if re.match(r"我最近", text):
                return "recent_status"
            if re.match(r"我在", text):
                return "location"
        if bucket == "preferences":
            match = re.match(r"我(?:更喜欢|不喜欢|喜欢|讨厌)(.{1,40})", text)
            if match:
                subject = re.sub(r"[\s，。,.!！？]", "", match.group(1)).lower()[:24]
                if subject:
                    return f"preference:{subject}"
        return f"text:{compact[:48]}" if compact else ""

    @staticmethod
    def _expiry_for(bucket: str, text: str) -> str | None:
        days = 0
        if bucket == "facts":
            if "明天" in text:
                days = 3
            elif "今天" in text or "正在" in text:
                days = 2
            elif "最近" in text:
                days = 14
            elif re.match(r"我(?:要|需要)", text):
                days = 30
            elif re.match(r"我在", text):
                days = 90
        elif bucket == "preferences" and re.match(r"我(?:希望|想要|想|怕)", text):
            days = 30
        if not days:
            return None
        return ConversationMemory._time_text(time.time() + days * 86400)

    def _normalize_item(self, bucket: str, value) -> dict | None:
        if isinstance(value, dict):
            text = self._clean_text(str(value.get("text", "")))
            if not text or self._looks_sensitive(text):
                return None
            created_at = str(value.get("created_at") or value.get("updated_at") or self._now_text())
            try:
                importance = max(1, min(5, int(value.get("importance", 3))))
            except (TypeError, ValueError):
                importance = 3
            previous_values = value.get("previous_values", [])
            if not isinstance(previous_values, list):
                previous_values = []
            item = {
                "id": str(value.get("id") or uuid.uuid4().hex[:12]),
                "text": text,
                "topic": str(value.get("topic") or self._topic_for(bucket, text)),
                "source": str(value.get("source") or "legacy"),
                "created_at": created_at,
                "updated_at": str(value.get("updated_at") or created_at),
                "expires_at": value.get("expires_at") or None,
                "importance": importance,
                "pinned": bool(value.get("pinned", False)),
                "previous_values": [item for item in previous_values[-5:] if isinstance(item, dict)],
            }
            return item
        text = self._clean_text(str(value or ""))
        if not text or self._looks_sensitive(text):
            return None
        now = self._now_text()
        return {
            "id": uuid.uuid4().hex[:12],
            "text": text,
            "topic": self._topic_for(bucket, text),
            "source": "legacy",
            "created_at": now,
            "updated_at": now,
            "expires_at": None,
            "importance": 3,
            "pinned": False,
            "previous_values": [],
        }

    def _prune_expired(self) -> bool:
        changed = False
        now = time.time()
        for bucket in self._BUCKET_LIMITS:
            kept = []
            for item in self.data.get(bucket, []):
                expires_at = self._parse_time(item.get("expires_at") if isinstance(item, dict) else None)
                if expires_at is not None and expires_at <= now:
                    changed = True
                    continue
                kept.append(item)
            if kept != self.data.get(bucket, []):
                self.data[bucket] = kept
        return changed

    def load(self) -> None:
        self.data = self._empty_data()
        if not self.path.exists():
            return
        try:
            loaded = json.loads(self.path.read_text(encoding="utf-8"))
            if isinstance(loaded, dict):
                migrated = int(loaded.get("version", 1) or 1) < 3
                for bucket in self._BUCKET_LIMITS:
                    values = loaded.get(bucket, [])
                    if not isinstance(values, list):
                        continue
                    items = [self._normalize_item(bucket, value) for value in values]
                    self.data[bucket] = [item for item in items if item is not None][
                        -self._BUCKET_LIMITS[bucket] :
                    ]
                    migrated = migrated or any(not isinstance(value, dict) for value in values)
                turns = loaded.get("recent_turns", [])
                if isinstance(turns, list):
                    self.data["recent_turns"] = [turn for turn in turns if isinstance(turn, dict)][
                        -self.recent_limit :
                    ]
                if self._prune_expired() or migrated:
                    self.save()
        except Exception:
            pass

    def reload(self) -> None:
        self.load()

    def save(self) -> None:
        try:
            self.path.parent.mkdir(parents=True, exist_ok=True)
            tmp = self.path.with_suffix(self.path.suffix + ".tmp")
            tmp.write_text(
                json.dumps(self.data, ensure_ascii=False, indent=2),
                encoding="utf-8",
            )
            os.replace(tmp, self.path)
        except Exception as exc:
            print(f"[Memory] save failed: {exc}")

    def clear(self) -> None:
        self.data = self._empty_data()
        try:
            if self.path.exists():
                self.path.unlink()
        except Exception:
            self.save()

    def _append_unique(
        self,
        bucket: str,
        text: str,
        limit: int,
        source: str = "auto",
        importance: int = 3,
        pinned: bool = False,
    ) -> None:
        text = self._clean_text(text)
        if not text or self._looks_sensitive(text):
            return
        now = self._now_text()
        topic = self._topic_for(bucket, text)
        importance = max(1, min(5, int(importance)))
        expires_at = None if pinned else self._expiry_for(bucket, text)
        items = list(self.data.get(bucket, []))
        replacement_index = None
        for index, item in enumerate(items):
            if not isinstance(item, dict):
                continue
            if item.get("text") == text or (topic and item.get("topic") == topic):
                replacement_index = index
        if replacement_index is not None:
            old = items.pop(replacement_index)
            item_id = str(old.get("id") or uuid.uuid4().hex[:12])
            created_at = str(old.get("created_at") or now)
            pinned = bool(old.get("pinned", False) or pinned)
            importance = max(importance, int(old.get("importance", 3) or 3))
            previous_values = list(old.get("previous_values", []))
            old_text = self._clean_text(str(old.get("text", "")))
            if old_text and old_text != text:
                previous_values.append({"text": old_text, "replaced_at": now})
            previous_values = previous_values[-5:]
        else:
            item_id = uuid.uuid4().hex[:12]
            created_at = now
            previous_values = []
        if pinned:
            expires_at = None
        items.append(
            {
                "id": item_id,
                "text": text,
                "topic": topic,
                "source": source,
                "created_at": created_at,
                "updated_at": now,
                "expires_at": expires_at,
                "importance": importance,
                "pinned": pinned,
                "previous_values": previous_values,
            }
        )
        self.data[bucket] = items[-limit:]

    def learn_from_user(self, user_text: str) -> None:
        text = self._clean_text(user_text)
        if not text or self._looks_sensitive(text):
            return

        remember_match = re.search(r"(?:记住|你要记得|帮我记住)[:：,，]?\s*(.{2,90})", text)
        if remember_match:
            self._append_unique(
                "notes",
                remember_match.group(1),
                40,
                source="explicit",
                importance=5,
                pinned=True,
            )

        call_match = re.search(r"(?:以后)?(?:叫我|喊我|称呼我)\s*([^\s，。,.!！？]{1,20})", text)
        if call_match:
            self._append_unique(
                "preferences",
                f"称呼用户为：{call_match.group(1)}",
                30,
                importance=5,
                pinned=True,
            )

        preference_patterns = (
            r"我(?:喜欢|不喜欢|讨厌|希望|想要|想|怕|习惯|更喜欢).{1,80}",
            r"以后(?:别|不要|可以|要).{1,80}",
            r"你(?:别|不要|可以|要).{1,80}",
        )
        for pattern in preference_patterns:
            for match in re.findall(pattern, text):
                stable = bool(re.match(r"我(?:喜欢|不喜欢|讨厌|习惯|更喜欢)", match))
                self._append_unique("preferences", match, 30, importance=4 if stable else 2)

        fact_patterns = (
            r"我(?:叫|是|在|正在|要|需要|最近|今天|明天).{1,80}",
        )
        for pattern in fact_patterns:
            for match in re.findall(pattern, text):
                self._append_unique("facts", match, 30, importance=2)

    def add_turn(self, user_text: str, assistant_text: str) -> None:
        user_text = self._clean_text(user_text, limit=180)
        assistant_text = self._clean_text(assistant_text, limit=180)
        if not user_text and not assistant_text:
            return
        if self._looks_sensitive(user_text) or self._looks_sensitive(assistant_text):
            return
        # Pick up edits made from the control panel while this reply was loading.
        self.reload()
        self.learn_from_user(user_text)
        turns = self.data.get("recent_turns", [])
        turns.append(
            {
                "id": uuid.uuid4().hex[:12],
                "time": self._now_text(),
                "user": user_text,
                "pet": assistant_text,
            }
        )
        self.data["recent_turns"] = turns[-self.recent_limit :]
        self.save()

    def replace_latest_turn(self, user_text: str, assistant_text: str) -> None:
        user_text = self._clean_text(user_text, limit=180)
        assistant_text = self._clean_text(assistant_text, limit=180)
        if self._looks_sensitive(user_text) or self._looks_sensitive(assistant_text):
            return
        self.reload()
        turns = self.data.get("recent_turns", [])
        for turn in reversed(turns):
            if isinstance(turn, dict) and self._clean_text(str(turn.get("user", "")), 180) == user_text:
                turn["pet"] = assistant_text
                turn["time"] = self._now_text()
                turn.setdefault("id", uuid.uuid4().hex[:12])
                self.save()
                return
        self.add_turn(user_text, assistant_text)

    def context(self, recent_limit: int = 8) -> str | None:
        if self._prune_expired():
            self.save()
        sections: list[str] = []
        if self.data.get("preferences"):
            items = sorted(
                self.data["preferences"],
                key=lambda item: (bool(item.get("pinned")), int(item.get("importance", 3))),
            )[-12:]
            lines = "\n".join(f"- {item.get('text', '')}" for item in items)
            sections.append(f"用户偏好：\n{lines}")
        if self.data.get("facts"):
            items = sorted(
                self.data["facts"],
                key=lambda item: (bool(item.get("pinned")), int(item.get("importance", 3))),
            )[-12:]
            lines = "\n".join(f"- {item.get('text', '')}" for item in items)
            sections.append(f"用户当前有效的近况/事实（临时内容不要当成永久信息）：\n{lines}")
        if self.data.get("notes"):
            items = sorted(
                self.data["notes"],
                key=lambda item: (bool(item.get("pinned")), int(item.get("importance", 3))),
            )[-12:]
            lines = "\n".join(f"- {item.get('text', '')}" for item in items)
            sections.append(f"用户明确要求记住的事：\n{lines}")
        turns = self.data.get("recent_turns", [])[-recent_limit:]
        if turns:
            lines = "\n".join(
                f"用户: {turn.get('user', '')}\n你: {turn.get('pet', '')}"
                for turn in turns
            )
            sections.append(f"最近对话：\n{lines}")
        if not sections:
            return None
        return "\n\n".join(sections)

    @property
    def enabled(self) -> bool:
        return True

class BibiReferenceRenderer:
    """Renders bibi character with optimized images.
    
    Priority:
      1. optimized_detail_no_black_edge_same_size.png (cleaner edge)
      2. optimized_detail_no_black_edge_2x.png (cleaner edge)
      3. optimized_detail_clean_same_size.png (user-optimized, same size as original)
      4. optimized_detail_clean_2x.png (user-optimized, 2x resolution)
      5. reference_full_walk.png + reference_full_wink.png (original split frames)
    """

    _DEFAULT_FILES = [
        "optimized_detail_no_black_edge_same_size.png",
        "optimized_detail_no_black_edge_2x.png",
        "optimized_detail_clean_same_size.png",
        "optimized_detail_clean_2x.png",
        "reference_full_walk.png",
        "reference_full_wink.png",
    ]

    _POSE_ALIASES = {
        "rest": ["idle", "rest", "neutral"],
        "look": ["look", "idle", "rest", "neutral"],
        "breathe": ["breathe", "idle", "rest", "neutral"],
        "glance": ["look", "idle", "rest"],
        "nod": ["nod", "look", "idle", "rest"],
        "shake": ["shake", "look", "idle", "rest"],
        "curious": ["curious", "look", "idle", "rest"],
        "listen": ["listen", "look", "idle"],
        "happy": ["happy", "smile", "idle"],
        "happy_soft": ["happy", "smile", "idle"],
        "settle_soft": ["happy", "smile", "idle"],
        "shy": ["shy", "happy", "smile", "idle"],
        "shy_soft": ["shy", "happy", "smile", "idle"],
        "shy_settle_soft": ["shy", "happy", "smile", "idle"],
        "blink": ["blink_seq", "blink", "idle", "look"],
        "wink": ["blink", "happy"],
        "wave": ["wave", "happy", "idle"],
        "walk": ["walk", "idle", "rest"],
        "typing": ["talk", "look"],
        "talk": ["talk", "talk_1", "happy", "look"],
        "drag": ["drag", "surprised", "look"],
        "mouse_watch": ["look", "idle", "rest"],
        "edge_peek": ["look", "idle", "rest"],
        "sleep": ["sleep", "idle", "rest"],
        "stretch": ["stretch", "wave", "happy", "idle"],
    }
    _POSE_HEIGHT_MULTIPLIERS = {
        # Raised arms extend beyond the normal standing silhouette. Keeping the
        # body at its normal scale prevents the stretch pose from shrinking.
        "stretch": 1.12,
    }

    def __init__(self, base_height: int, bibi_dir: str | None = None):
        from PIL import Image

        self.base_height = base_height
        self._Image = Image
        self._image_anchors: dict[int, float] = {}

        if bibi_dir:
            bibi_path = Path(bibi_dir)
        else:
            # Default: use bibi/ directory (contains user-optimized images)
            bibi_path = Path(_image_path("bibi"))

        self.bibi_path = bibi_path
        self.pose_images: dict[str, "Image.Image"] = {}
        self.pose_sequences: dict[str, list["Image.Image"]] = {}
        self.default_image = self._load_first(self._DEFAULT_FILES)
        if self.default_image is None:
            raise RuntimeError("找不到可用的bibi角色图片")

        for action, aliases in self._POSE_ALIASES.items():
            target_height = int(round(self.base_height * self._POSE_HEIGHT_MULTIPLIERS.get(action, 1.0)))
            sequence = self._load_pose_sequence(aliases, target_height=target_height)
            if sequence:
                self.pose_images[action] = sequence[0]
                if len(sequence) > 1:
                    self.pose_sequences[action] = sequence

        self._stable_image_ids = {id(self.default_image), *[id(img) for img in self.pose_images.values()]}
        for sequence in self.pose_sequences.values():
            self._stable_image_ids.update(id(img) for img in sequence)

        self.walk_image = self.pose_images.get("walk", self.default_image)
        self.wink_image = self.pose_images.get("wink", self.default_image)
        self.idle_image = self.pose_images.get("rest", self.default_image)

        all_images = [self.default_image, *self.pose_images.values()]
        max_h = max(img.height for img in all_images)
        max_left = max(self._image_anchor_x(img) for img in all_images)
        max_right = max(img.width - self._image_anchor_x(img) for img in all_images)
        self._stage_anchor_x = int(math.ceil(max_left + 4))
        self.stage_w = max(72, int(math.ceil(max_left + max_right + 8)))
        _, self.stage_h = _build_stage(1, max_h)

    @staticmethod
    def available(bibi_dir: str | None = None) -> bool:
        if bibi_dir:
            p = Path(bibi_dir)
        else:
            p = Path(_image_path("bibi"))
        poses = p / "poses"
        return (p / "optimized_detail_no_black_edge_same_size.png").exists() \
            or (p / "optimized_detail_no_black_edge_2x.png").exists() \
            or (p / "optimized_detail_clean_same_size.png").exists() \
            or (p / "optimized_detail_clean_2x.png").exists() \
            or (p / "reference_full_walk.png").exists() \
            or (poses.exists() and any(poses.glob("*.png")))

    def _load_first(self, filenames: list[str]):
        for filename in filenames:
            img = self._load_and_prepare(str(self.bibi_path / filename), self.base_height)
            if img is not None:
                return img
        return None

    def _pose_candidates(self, names: list[str]) -> list[Path]:
        candidates: list[Path] = []
        roots = [self.bibi_path / "poses", self.bibi_path]
        prefixes = ["", "bibi_", "pose_"]
        for root in roots:
            for name in names:
                for prefix in prefixes:
                    candidates.append(root / f"{prefix}{name}.png")
        return candidates

    def _pose_sequence_candidates(self, name: str, prefix: str, root: Path) -> list[Path]:
        base_name = f"{prefix}{name}"
        found: list[tuple[int, Path]] = []
        base = root / f"{base_name}.png"
        if base.exists():
            found.append((0, base))
        for path in root.glob(f"{base_name}_*.png"):
            suffix = path.stem[len(base_name) + 1 :]
            if suffix.isdigit():
                found.append((int(suffix), path))
        seen: set[Path] = set()
        result: list[Path] = []
        for _, path in sorted(found, key=lambda item: (item[0], str(item[1]).lower())):
            if path not in seen:
                seen.add(path)
                result.append(path)
        return result

    def _load_pose(self, names: list[str]):
        for path in self._pose_candidates(names):
            img = self._load_and_prepare(str(path), self.base_height)
            if img is not None:
                return img
        return None

    def _load_pose_sequence(self, names: list[str], target_height: int | None = None):
        roots = [self.bibi_path / "poses", self.bibi_path]
        prefixes = ["", "bibi_", "pose_"]
        for root in roots:
            for name in names:
                for prefix in prefixes:
                    loaded = []
                    for path in self._pose_sequence_candidates(name, prefix, root):
                        img = self._load_sequence_source(str(path))
                        if img is not None:
                            loaded.append(img)
                    if loaded:
                        return self._collapse_duplicate_sequence(
                            self._prepare_pose_sequence(loaded, target_height=target_height)
                        )
        return []

    def _load_sequence_source(self, path: str):
        if not os.path.exists(path):
            return None
        img = self._Image.open(path).convert("RGBA")
        img = _remove_soft_alpha(img, min_alpha=24, keep_soft=True)
        return _remove_dark_alpha_fringe(img)

    def _prepare_pose_sequence(self, images: list, target_height: int | None = None):
        boxes = [img.getchannel("A").getbbox() for img in images]
        visible_boxes = [bbox for bbox in boxes if bbox]
        if not visible_boxes:
            return images
        union_box = (
            min(bbox[0] for bbox in visible_boxes),
            min(bbox[1] for bbox in visible_boxes),
            max(bbox[2] for bbox in visible_boxes),
            max(bbox[3] for bbox in visible_boxes),
        )
        height = max(1, int(target_height or self.base_height))
        prepared = [_resize_height(img.crop(union_box), height) for img in images]
        return self._normalize_pose_sequence(prepared)

    def _normalize_pose_sequence(self, images: list):
        if len(images) <= 1:
            return images
        anchors = [self._body_anchor_x(img) for img in images]
        bottoms = []
        for img in images:
            bbox = img.getchannel("A").getbbox()
            bottoms.append(float(bbox[3]) if bbox else float(img.height))
        left = int(math.ceil(max(anchors) + 3))
        right = int(math.ceil(max(img.width - anchor for img, anchor in zip(images, anchors)) + 3))
        stage_w = max(1, left + right)
        stage_h = max(img.height for img in images)
        normalized = []
        for img, anchor, bottom in zip(images, anchors, bottoms):
            canvas = self._Image.new("RGBA", (stage_w, stage_h), (0, 0, 0, 0))
            x = int(round(left - anchor))
            y = int(round(stage_h - bottom))
            canvas.alpha_composite(img, (x, y))
            self._image_anchors[id(canvas)] = float(left)
            normalized.append(canvas)
        return normalized

    def _collapse_duplicate_sequence(self, images: list):
        if len(images) <= 1:
            return images
        first = images[0]
        try:
            first_bytes = first.tobytes()
            if all(img.size == first.size and img.tobytes() == first_bytes for img in images[1:]):
                return [first]
        except Exception:
            pass
        return images

    def _has_motion_frames(self, action: str) -> bool:
        return len(self.pose_sequences.get(action, ())) > 1

    def _load_and_prepare(self, path: str, height: int):
        if not os.path.exists(path):
            return None
        img = self._Image.open(path).convert("RGBA")
        img = _remove_soft_alpha(img, min_alpha=24, keep_soft=True)
        img = _remove_dark_alpha_fringe(img)
        img = _crop_non_empty_area(img)
        img = _resize_height(img, height)
        self._image_anchors[id(img)] = self._body_anchor_x(img)
        return img

    def _body_anchor_x(self, img) -> float:
        try:
            alpha = img.getchannel("A")
            y0 = max(0, min(img.height - 1, int(img.height * 0.68)))
            y1 = max(y0 + 1, min(img.height, int(img.height * 0.99)))
            band = alpha.crop((0, y0, img.width, y1)).point(lambda a: 255 if a >= 32 else 0)
            bbox = band.getbbox()
            if bbox:
                return bbox[0] + (bbox[2] - bbox[0]) / 2.0
        except Exception:
            pass
        return img.width / 2.0

    def _image_anchor_x(self, img) -> float:
        if id(img) not in self._stable_image_ids:
            return self._body_anchor_x(img)
        anchor = self._image_anchors.get(id(img))
        if anchor is None:
            anchor = self._body_anchor_x(img)
            self._image_anchors[id(img)] = anchor
        return anchor

    def _image_for_action(self, action: str, t: float | None = None):
        if t is not None and action in self.pose_sequences:
            sequence = self.pose_sequences[action]
            if sequence:
                if action == "sleep":
                    speed = 6.0
                elif action == "wave":
                    speed = 24.0
                elif action == "stretch":
                    speed = 12.0
                elif action in {"talk", "typing"}:
                    speed = 20.0
                elif action == "blink":
                    speed = 6.8
                elif action == "happy":
                    speed = 16.0
                elif action == "shy":
                    speed = 10.0
                elif action == "nod":
                    speed = 14.0
                elif action == "shake":
                    speed = 16.0
                elif action == "curious":
                    speed = 8.0
                elif action == "listen":
                    speed = 10.0
                elif action == "walk":
                    speed = 14.0
                elif action == "breathe":
                    speed = 8.0
                elif action in {"mouse_watch", "edge_peek"}:
                    speed = 0.16
                else:
                    speed = 0.32
                if len(sequence) == 1:
                    return sequence[0]
                if action in {"nod", "shake", "walk", "listen", "shy", "stretch"}:
                    return sequence[int(t * speed) % len(sequence)]
                track = list(range(len(sequence))) + list(range(len(sequence) - 2, 0, -1))
                pos = (t * speed) % len(track)
                idx0 = int(math.floor(pos))
                if action != "blink":
                    return sequence[track[idx0]]
                idx1 = (idx0 + 1) % len(track)
                frame0 = sequence[track[idx0]]
                frame1 = sequence[track[idx1]]
                alpha = pos - idx0
                alpha = alpha * alpha * (3.0 - 2.0 * alpha)
                if alpha <= 0.01:
                    return frame0
                if alpha >= 0.99:
                    return frame1
                return _blend_transition_images(frame0.convert("RGBA"), frame1.convert("RGBA"), alpha, masked=True)
        return self.pose_images.get(action) or self.pose_images.get("rest") or self.default_image

    def _canvas_draw(self, img, off_x: int = 0, off_y: int = 0, scale_x: float = 1.0, scale_y: float = 1.0):
        if abs(scale_x - 1.0) > 0.001 or abs(scale_y - 1.0) > 0.001:
            target_w = max(1, int(round(img.size[0] * scale_x)))
            target_h = max(1, int(round(img.size[1] * scale_y)))
            if abs(target_w - img.size[0]) >= 2 or abs(target_h - img.size[1]) >= 2:
                img = _remove_dark_alpha_fringe(img.resize((target_w, target_h), self._Image.Resampling.BICUBIC))
        canvas = self._Image.new("RGBA", (self.stage_w, self.stage_h), (1, 1, 1, 0))
        x = int(round(self._stage_anchor_x - self._image_anchor_x(img) + off_x))
        y = self.stage_h - img.size[1] + int(off_y)
        x = max(0, min(x, self.stage_w - img.size[0]))
        y = max(0, min(y, self.stage_h - img.size[1]))
        canvas.alpha_composite(img, (x, y))
        return canvas

    def _smooth_cycle(self, t: float, speed: float) -> float:
        return 0.5 - 0.5 * math.cos(t * math.tau * speed)

    def _blend_pose_canvases(
        self,
        base_action: str,
        target_action: str,
        t: float,
        alpha: float,
        off_x: int = 0,
        off_y: int = 0,
        masked: bool = False,
    ):
        base = self._canvas_draw(self._image_for_action(base_action, t), off_x=off_x, off_y=off_y)
        target = self._canvas_draw(self._image_for_action(target_action, t), off_x=off_x, off_y=off_y)
        alpha = max(0.0, min(1.0, alpha))
        return _blend_transition_images(base, target, alpha, masked)

    def _entry_pose_canvas(self, target_action: str, t: float, base_action: str = "happy"):
        if t >= _POSE_ENTRY_TRANSITION_SECONDS:
            return None
        progress = max(0.0, min(1.0, t / max(0.01, _POSE_ENTRY_TRANSITION_SECONDS)))
        progress = progress * progress * (3.0 - 2.0 * progress)
        base = self._canvas_draw(self._image_for_action(base_action, t))
        target = self._canvas_draw(self._image_for_action(target_action, t))
        return _pose_switch_transition_images(base, target, progress, base_action, target_action)

    def _exit_pose_canvas(self, source_action: str, t: float, target_action: str = "happy"):
        start = _POSE_EXIT_START_SECONDS.get(source_action)
        if start is None or t < start:
            return None
        progress = max(0.0, min(1.0, (t - start) / max(0.01, _POSE_EXIT_TRANSITION_SECONDS)))
        progress = progress * progress * (3.0 - 2.0 * progress)
        source = self._canvas_draw(self._image_for_action(source_action, t))
        target = self._canvas_draw(self._image_for_action(target_action, t))
        return _pose_switch_transition_images(source, target, progress, source_action, target_action)

    def render(self, action: str, t: float, delta: float = 0.0) -> RendererFrame:
        breath = 0.5 - 0.5 * math.cos(t * math.pi * 0.38)
        scale_x = 1.0
        scale_y = 1.0
        calm_scale_x = 1.0
        calm_scale_y = 1.0
        if action == "walk":
            img = self._image_for_action("walk", t)
            if self._has_motion_frames("walk"):
                off_x = int(round(math.sin(t * 4.0) * 1.0))
                off_y = -int(round(1.0 + breath * 1.0))
            else:
                off_x = 0
                off_y = 0
        elif action == "wave":
            img = self._image_for_action("wave", t)
            off_x = 0
            off_y = 0
        elif action == "wink":
            img = self._image_for_action("wink", t)
            off_x = 0
            off_y = 0
        elif action == "blink":
            img = self._image_for_action("blink", t)
            off_x = 0
            off_y = 0
            scale_x = calm_scale_x
            scale_y = calm_scale_y
        elif action == "happy":
            img = self._image_for_action("happy", t)
            off_x = 0
            off_y = 0
        elif action == "happy_soft":
            img = self._image_for_action("happy", t)
            off_x = 0
            off_y = -int(round(1.0 + breath * 1.0))
            scale_x = calm_scale_x
            scale_y = calm_scale_y
        elif action == "settle_soft":
            img = self._image_for_action("breathe", t)
            off_x = 0
            off_y = int(round(breath * 0.8))
            scale_x = calm_scale_x
            scale_y = calm_scale_y
        elif action == "shy":
            img = self._image_for_action("shy", t)
            off_x = 0
            off_y = 0
        elif action == "shy_soft":
            img = self._image_for_action("shy", t)
            off_x = -int(round(1.0 + math.sin(t * 2.0) * 0.5))
            off_y = int(round(breath * 0.8))
            scale_x = 1.0 - math.sin(t * math.pi * 1.2) * 0.004
            scale_y = 1.0 + math.sin(t * math.pi * 1.2) * 0.006
        elif action == "shy_settle_soft":
            img = self._image_for_action("breathe", t)
            off_x = 0
            off_y = int(round(breath * 0.8))
            scale_x = calm_scale_x
            scale_y = calm_scale_y
        elif action == "typing":
            entry_canvas = self._entry_pose_canvas("typing", t, base_action="look")
            if entry_canvas is not None:
                return RendererFrame(entry_canvas, offset_y=0, offset_x=0)
            exit_canvas = self._exit_pose_canvas("typing", t, target_action="look")
            if exit_canvas is not None:
                return RendererFrame(exit_canvas, offset_y=0, offset_x=0)
            img = self._image_for_action("typing", t)
            off_x = 0
            off_y = 0
        elif action == "talk":
            img = self._image_for_action("talk", t)
            off_x = 0
            off_y = 0
            scale_x = calm_scale_x
            scale_y = calm_scale_y
        elif action == "drag":
            img = self._image_for_action("drag", t)
            off_x = 0
            off_y = 0
        elif action == "glance":
            img = self._image_for_action("glance", t)
            off_x = int(round(math.sin(t * 1.6) * 0.8))
            off_y = 0
            scale_x = calm_scale_x
            scale_y = calm_scale_y
        elif action == "breathe":
            img = self._image_for_action("breathe", t)
            off_x = 0
            off_y = 0
            scale_x = calm_scale_x
            scale_y = calm_scale_y
        elif action == "listen":
            img = self._image_for_action("listen", t)
            off_x = 0
            off_y = 0
            scale_x = calm_scale_x
            scale_y = calm_scale_y
        elif action in {"nod", "shake", "curious"}:
            img = self._image_for_action(action, t)
            off_x = 0
            off_y = 0
            scale_x = calm_scale_x
            scale_y = calm_scale_y
        elif action == "mouse_watch":
            img = self._image_for_action("mouse_watch", t)
            off_x = 0
            off_y = 0
            scale_x = calm_scale_x
            scale_y = calm_scale_y
        elif action == "edge_peek":
            img = self._image_for_action("edge_peek", t)
            off_x = 0
            off_y = 0
            scale_x = calm_scale_x
            scale_y = calm_scale_y
        elif action == "sleep":
            img = self._image_for_action("sleep", t)
            off_x = 0
            off_y = 0
        elif action == "wake_soft":
            img = self._image_for_action("rest", t)
            wake = max(0.0, min(1.0, t / 0.45))
            wake = wake * wake * (3.0 - 2.0 * wake)
            off_x = 0
            off_y = int(round(3.0 * (1.0 - wake)))
            scale_x = 1.0
            scale_y = 0.985 + 0.015 * wake
        elif action == "stretch":
            img = self._image_for_action("stretch", t)
            off_x = 0
            off_y = 0
        else:
            img = self._image_for_action("look" if action == "look" else "rest", t)
            off_x = 0
            off_y = 0
            scale_x = calm_scale_x
            scale_y = calm_scale_y

        canvas = self._canvas_draw(img, off_x=off_x, off_y=off_y, scale_x=scale_x, scale_y=scale_y)
        return RendererFrame(canvas, offset_y=0, offset_x=0)


class ModelGFRenderer:
    """Renders model_gf character references using layered sprites when available."""

    _LAYER_FILES = [
        "body_outfit_stand_front.png",
        "body_outfit_walk_front.png",
        "leg_pants_pose_01.png",
        "leg_pants_pose_02.png",
        "arm_sleeve_pose_01.png",
        "arm_sleeve_pose_02.png",
        "arm_sleeve_pose_03.png",
        "arm_sleeve_pose_04.png",
        "hand_open_pose_01.png",
        "hand_open_pose_02.png",
        "head_base_neck.png",
        "head_complete_front.png",
        "hair_back_long.png",
        "hair_front_long.png",
        "eyes_open.png",
        "eyes_closed.png",
        "mouth_smile_open.png",
    ]

    def __init__(self, base_height: int, model_dir: str | None = None):
        from PIL import Image

        self.base_height = base_height
        self._Image = Image

        if model_dir:
            model_path = Path(model_dir)
        else:
            clean_model = Path(_image_path("assets", "model_gf_clean"))
            raw_model = Path(_image_path("assets", "model_gf"))
            model_path = clean_model if clean_model.exists() else raw_model

        self.model_path = model_path
        self._layer_assets: dict[str, "Image.Image"] = {}
        self._load_layer_assets()
        self._fallback_only = not bool(self._layer_assets)

        if self._fallback_only:
            walk_orig = model_path / "reference_full_walk.png"
            wink_orig = model_path / "reference_full_wink.png"
            self.walk_image = self._load_and_prepare(str(walk_orig), base_height)
            self.wink_image = self._load_and_prepare(str(wink_orig), base_height)
            self.idle_image = self.wink_image or self.walk_image
            if self.walk_image is None:
                raise RuntimeError("找不到可用的 model_gf 角色图片")
            self.body_stand = self.walk_image
        else:
            self.body_stand = self._layer_assets.get("body_outfit_stand_front")
            self.body_walk = self._layer_assets.get("body_outfit_walk_front") or self.body_stand
            self.leg_1 = self._layer_assets.get("leg_pants_pose_01")
            self.leg_2 = self._layer_assets.get("leg_pants_pose_02")
            self.arm_1 = self._layer_assets.get("arm_sleeve_pose_01")
            self.arm_2 = self._layer_assets.get("arm_sleeve_pose_02")
            self.arm_3 = self._layer_assets.get("arm_sleeve_pose_03")
            self.arm_4 = self._layer_assets.get("arm_sleeve_pose_04")
            self.hand_1 = self._layer_assets.get("hand_open_pose_01")
            self.hand_2 = self._layer_assets.get("hand_open_pose_02")
            self.head_base = self._layer_assets.get("head_base_neck")
            self.head_full = self._layer_assets.get("head_complete_front") or self.head_base
            self.hair_back = self._layer_assets.get("hair_back_long")
            self.hair_front = self._layer_assets.get("hair_front_long")
            self.eyes_open = self._layer_assets.get("eyes_open")
            self.eyes_closed = self._layer_assets.get("eyes_closed")
            self.mouth = self._layer_assets.get("mouth_smile_open")

            if self.body_stand is None:
                self.body_stand = self.body_walk
            if self.body_stand is None:
                raise RuntimeError("找不到可用的 model_gf 角色图片")

    @staticmethod
    def available(model_dir: str | None = None) -> bool:
        if model_dir:
            p = Path(model_dir)
        else:
            clean_model = Path(_image_path("assets", "model_gf_clean"))
            raw_model = Path(_image_path("assets", "model_gf"))
            p = clean_model if clean_model.exists() else raw_model
        return (
            (p / "reference_full_walk.png").exists()
            or (p / "reference_full_wink.png").exists()
            or (p / "body_outfit_stand_front.png").exists()
            or (p / "body_outfit_walk_front.png").exists()
        )

    @staticmethod
    def _pingpong(t: float, speed: float = 2.0) -> float:
        return 0.5 - 0.5 * math.cos(t * speed * math.pi)

    @staticmethod
    def _blend(a: "Image.Image" | None, b: "Image.Image" | None, ratio: float):
        if a is None:
            return b
        if b is None:
            return a
        ratio = max(0.0, min(1.0, ratio))
        from PIL import Image as _PILImage

        return _PILImage.blend(a, b, ratio)

    def _load_layer_assets(self) -> None:
        if not self.model_path.exists():
            return
        for fname in self._LAYER_FILES:
            key = Path(fname).stem
            fpath = self.model_path / fname
            if not fpath.exists():
                continue
            try:
                img = self._Image.open(fpath).convert("RGBA")
                img = _remove_soft_alpha(img, min_alpha=24)
                self._layer_assets[key] = _resize_height(img, self.base_height)
            except Exception:
                continue

        # Keep fallback alive for incomplete custom folders.
        if "head_base_neck" not in self._layer_assets:
            self._layer_assets = {}
            return
        if "body_outfit_stand_front" not in self._layer_assets and "body_outfit_walk_front" not in self._layer_assets:
            self._layer_assets = {}

    def _load_and_prepare(self, path: str, height: int):
        if not os.path.exists(path):
            return None
        img = self._Image.open(path).convert("RGBA")
        img = _remove_soft_alpha(img, min_alpha=22)
        img = _crop_non_empty_area(img)
        return _resize_height(img, height)

    def _build_head(self, t: float, action: str):
        base = self.head_base
        face = self._blend(self.head_full, base, 0.2) if self.head_full else base

        # Blink schedule: mostly open, occasional short close.
        r = (t % 5.0) / 5.0
        if action in {"wink", "blink"}:
            eye_ratio = 0.95
        elif r < 0.02:
            eye_ratio = 1.0
        elif r > 0.965:
            eye_ratio = max(0.0, (1.0 - r) / 0.035)
        else:
            eye_ratio = 0.0
        eye = self._blend(self.eyes_open, self.eyes_closed, eye_ratio)
        return face, eye, self.mouth

    def _compose(self, body, leg, arm, hand, face, eye, mouth, off_x, off_y):
        w, h = body.size
        stack = self._Image.new("RGBA", (w, h), (1, 1, 1, 0))

        for layer in (leg, arm, hand, self.hair_back, face, eye, mouth, self.hair_front):
            if layer is not None:
                stack.paste(layer, (0, 0), layer)

        stage_w, stage_h = _build_stage(w, h)
        canvas = self._Image.new("RGBA", (stage_w, stage_h), (1, 1, 1, 0))
        canvas.paste(body, (0, 0), body)
        out = self._Image.new("RGBA", (stage_w, stage_h), (1, 1, 1, 0))
        x = (stage_w - w) // 2 + off_x
        y = stage_h - h + off_y
        out.paste(stack, (x, y), stack)
        return out

    def _render_layered(self, action: str, t: float) -> RendererFrame:
        if action == "walk":
            body = self.body_walk
            leg = self._blend(self.leg_1, self.leg_2, self._pingpong(t, 2.8))
            arm = self._blend(self.arm_1, self.arm_2, self._pingpong(t, 2.0))
            hand = self._blend(self.hand_1, self.hand_2, self._pingpong(t, 1.4))
            off_x = int(math.sin(t * 1.8) * 1.2)
            off_y = int(2 + 1.2 * math.sin(t * 2.2) + 1.6 * self._pingpong(t, 2.0))
        elif action == "wave":
            body = self.body_stand
            leg = self._blend(self.leg_1, self.leg_2, 0.1 + 0.9 * self._pingpong(t, 1.4))
            arm = self._blend(self.arm_2, self._blend(self.arm_3, self.arm_4, self._pingpong(t, 8.0)), self._pingpong(t, 4.0))
            hand = self._blend(self.hand_1, self.hand_2, 0.5 + 0.5 * self._pingpong(t, 4.0))
            off_x = int(1.5 * self._pingpong(t, 4.0) * (1 if t % 2 < 1 else -1))
            off_y = int(-1 + math.sin(t * 6.0) * 1.1)
        elif action in {"wink", "blink"}:
            body = self.body_stand
            leg = self._blend(self.leg_1, self.leg_2, 0.6)
            arm = self._blend(self.arm_1, self.arm_4, 0.35)
            hand = self.hand_1
            off_x = 0
            off_y = 1
        elif action == "typing":
            body = self.body_stand
            leg = self._blend(self.leg_1, self.leg_2, 0.2 + 0.8 * self._pingpong(t, 1.2))
            arm = self._blend(self.arm_1, self.arm_4, self._pingpong(t, 3.2))
            hand = self._blend(self.hand_1, self.hand_2, 0.35 + 0.35 * self._pingpong(t, 2.0))
            off_x = int(math.sin(t * 3.0) * 1.0)
            off_y = 0
        elif action == "sleep":
            body = self.body_stand
            leg = self._blend(self.leg_1, self.leg_2, 0.35)
            arm = self._blend(self.arm_1, self.arm_2, 0.18)
            hand = self.hand_1
            off_x = 0
            off_y = int(2 + self._pingpong(t, 0.45))
        elif action == "stretch":
            body = self.body_stand
            leg = self._blend(self.leg_1, self.leg_2, 0.65)
            arm = self._blend(self.arm_2, self.arm_4, 0.65 + 0.25 * self._pingpong(t, 2.0))
            hand = self._blend(self.hand_1, self.hand_2, 0.7)
            off_x = int(math.sin(t * 2.2) * 1.2)
            off_y = int(-2 - 1.5 * self._pingpong(t, 1.3))
        elif action in {"mouse_watch", "edge_peek", "glance"}:
            body = self.body_stand
            leg = self._blend(self.leg_1, self.leg_2, 0.45)
            arm = self._blend(self.arm_1, self.arm_2, 0.28 + 0.12 * self._pingpong(t, 1.0))
            hand = self.hand_1
            off_x = int(math.sin(t * 1.4) * 0.7)
            off_y = int(self._pingpong(t, 0.6))
        else:  # rest/look
            body = self.body_stand
            leg = self._blend(self.leg_1, self.leg_2, 0.4 + 0.15 * self._pingpong(t, 0.8))
            arm = self._blend(self.arm_1, self.arm_2, 0.3 + 0.2 * self._pingpong(t, 1.0))
            hand = self.hand_1
            off_x = int(math.sin(t * 0.9) * 0.8)
            off_y = int(1 + self._pingpong(t, 0.7) + (math.sin(t * 0.5) * 0.7))

        face, eye, mouth = self._build_head(t, action)
        image = self._compose(body, leg, arm, hand, face, eye, mouth, off_x, off_y)
        return RendererFrame(image, offset_x=off_x, offset_y=off_y)

    def render(self, action: str, t: float, delta: float = 0.0) -> RendererFrame:
        if self._fallback_only:
            if action == "walk":
                base = self.walk_image
                bob = int(4.0 * (0.5 + 0.5 * (abs((t % 0.5) - 0.25) * 4)))
                off_x = int(3.0 * (1 if (int(t * 2) % 2 == 0) else -1))
                off_y = bob
                img = base.transpose(self._Image.FLIP_LEFT_RIGHT) if (int(t * 6) % 2 == 0) else base
            elif action == "wave":
                base = self.wink_image or self.walk_image
                angle = 8 * (0.5 - abs(0.5 - ((t * 4) % 1)))
                img = base.rotate(angle, resample=self._Image.Resampling.BICUBIC, expand=True)
                off_y = int(-2 + 4 * (0.5 - abs(0.5 - ((t * 2) % 1))))
                off_x = 1 if (int(t * 8) % 2 == 0) else -1
            elif action in {"wink", "blink"}:
                base = self.wink_image or self.walk_image
                off_y = int(1 * abs((1.0 - (t * 6) % 1.0 * 2)))
                off_x = 0
                img = base
            elif action == "sleep":
                base = self.idle_image
                off_x = 0
                off_y = int(2 + 1.5 * (0.5 + 0.5 * math.sin(t * 1.0)))
                img = base
            elif action == "stretch":
                base = self.wink_image or self.walk_image
                angle = 4 * math.sin(t * 2.5)
                img = base.rotate(angle, resample=self._Image.Resampling.BICUBIC, expand=True)
                off_x = int(math.sin(t * 2.0) * 1.2)
                off_y = -2
            elif action in {"mouse_watch", "edge_peek", "glance"}:
                base = self.idle_image
                off_x = int(math.sin(t * 1.4) * 0.8)
                off_y = 0
                img = base
            else:
                base = self.idle_image
                off_x = 0
                off_y = 0
                img = base

            stage_w, stage_h = _build_stage(img.size[0], img.size[1])
            canvas = self._Image.new("RGBA", (stage_w, stage_h), (1, 1, 1, 0))
            x = (stage_w - img.width) // 2 + off_x
            y = stage_h - img.height + off_y
            canvas.paste(img, (x, y), img)
            return RendererFrame(canvas, offset_x=off_x, offset_y=off_y)

        return self._render_layered(action, t)

class SequenceFrameRenderer:
    """Renders pre-made sequence frames with animation config."""
    
    def __init__(self, base_height: int, source_dir: str | None = None):
        from PIL import Image
        import json

        self._Image = Image
        self.base_height = base_height

        if source_dir:
            src_path = Path(source_dir)
        else:
            # Auto-detect: prefer optimized, fallback to source
            opt = Path(_image_path("assets", "source_optimized"))
            src = Path(_image_path("assets", "source"))
            src_path = opt if opt.exists() and any(opt.glob("*.png")) else src

        self.source_dir = src_path

        # Load animation config if available
        self.anim_config = {}
        config_path = src_path / "animation.json"
        if config_path.exists():
            with open(config_path, "r", encoding="utf-8-sig") as f:
                self.anim_config = json.load(f).get("actions", {})

        # Pre-load all frames grouped by action
        self.action_frames: dict[str, list] = {}
        self._load_all_frames()

        if not self.action_frames:
            raise RuntimeError(f"未找到可用的角色素材：{src_path}")

    def _load_all_frames(self):
        from PIL import Image
        
        # Use animation config to find frames if available
        if self.anim_config:
            for action, frames_cfg in self.anim_config.items():
                loaded = []
                for fc in frames_cfg:
                    fname = fc.get("file", "")
                    fpath = self.source_dir / fname
                    if fpath.exists():
                        img = Image.open(str(fpath)).convert("RGBA")
                        # Already pre-cropped and padded by optimize_assets.py
                        # Just resize to target height
                        img = _resize_height(img, self.base_height)
                        loaded.append(img)
                if loaded:
                    self.action_frames[action] = loaded
        
        # If no config, try to auto-detect from filenames
        if not self.action_frames:
            import re
            groups: dict[str, list[tuple[int, "Image.Image"]]] = {}
            for fname in sorted(self.source_dir.glob("*.png")):
                # Match pattern: prefix_action_NN.png
                name = fname.stem
                parts = name.rsplit("_", 1)
                if len(parts) == 2 and parts[1].isdigit():
                    action = parts[0].replace("girlfriend_q_", "")
                    idx = int(parts[1])
                    img = Image.open(str(fname)).convert("RGBA")
                    img = _resize_height(_crop_non_empty_area(img), self.base_height)
                    groups.setdefault(action, []).append((idx, img))
            for action, items in groups.items():
                items.sort(key=lambda x: x[0])
                self.action_frames[action] = [img for _, img in items]

    @staticmethod
    def available(source_dir: str | None = None) -> bool:
        if source_dir:
            p = Path(source_dir)
        else:
            opt = Path(_image_path("assets", "source_optimized"))
            src = Path(_image_path("assets", "source"))
            p = opt if opt.exists() and any(opt.glob("*.png")) else src
        return p.exists() and any(p.glob("*.png"))

    def _get_frames(self, action: str) -> list:
        """Get frames for an action, with smart fallback."""
        # Direct match
        if action in self.action_frames:
            return self.action_frames[action]
        # Map common actions to available sets
        fallback_map = {
            "look": ["look", "idle", "rest"],
            "glance": ["look", "idle", "rest"],
            "rest": ["rest", "idle", "look"],
            "idle": ["idle", "rest", "look"],
            "blink": ["blink", "idle", "look"],
            "wink": ["blink", "wink", "wave", "look"],
            "wave": ["wave", "look"],
            "walk": ["walk", "wave", "rest"],
            "typing": ["typing", "rest", "look"],
            "mouse_watch": ["look", "idle", "rest"],
            "edge_peek": ["look", "idle", "rest"],
            "sleep": ["sleep", "rest", "idle"],
            "stretch": ["stretch", "wave", "look"],
        }
        for fallback in fallback_map.get(action, [action]):
            if fallback in self.action_frames:
                return self.action_frames[fallback]
        # Last resort: return any available
        for frames in self.action_frames.values():
            return frames
        return []

    def render(self, action: str, t: float, delta: float = 0.0) -> RendererFrame:
        frames = self._get_frames(action)
        if not frames:
            return RendererFrame(
                self._Image.new("RGBA", (72, 72), (1, 1, 1, 0))
            )

        # Use animation config for timing if available
        if action in self.anim_config:
            cfg_frames = self.anim_config[action]
            total_duration = sum(f.get("duration", 0.1) for f in cfg_frames)
            elapsed = t % total_duration
            accumulated = 0.0
            idx = 0
            for i, fc in enumerate(cfg_frames):
                accumulated += fc.get("duration", 0.1)
                if elapsed < accumulated:
                    idx = i
                    break
            idx = min(idx, len(frames) - 1)
        else:
            idx = int(t * 6) % len(frames)

        img = frames[idx]
        stage_w, stage_h = _build_stage(img.width, img.height)
        canvas = self._Image.new("RGBA", (stage_w, stage_h), (1, 1, 1, 0))
        x = (stage_w - img.width) // 2
        y = stage_h - img.height
        canvas.paste(img, (x, y), img)
        return RendererFrame(canvas, 0, 0)


class PuppetMeshRenderer:
    """Single-art mesh puppet that avoids full-pose PNG switching."""

    def __init__(self, base_height: int, bibi_dir: str | None = None):
        from PIL import Image, ImageDraw

        self._Image = Image
        self._ImageDraw = ImageDraw
        self.root = Path(bibi_dir or _image_path("bibi"))
        base_path = self.root / "poses" / "idle.png"
        if not base_path.exists():
            raise RuntimeError("网格人物缺少 bibi/poses/idle.png")
        raw = Image.open(base_path).convert("RGBA")
        raw = _remove_dark_alpha_fringe(_remove_soft_alpha(raw, min_alpha=20, keep_soft=True))
        self._source_bbox = raw.getbbox() or (0, 0, raw.width, raw.height)
        self.base = self._prepare(raw, base_height)
        self.stage_w, self.stage_h = _build_stage(self.base.width, self.base.height)
        self._grid_columns = 6
        self._grid_rows = 12
        self._pointer_target_x = 0.0
        self._pointer_target_y = 0.0
        self._pointer_x = 0.0
        self._pointer_y = 0.0

    @staticmethod
    def available(bibi_dir: str | None = None) -> bool:
        root = Path(bibi_dir or _image_path("bibi"))
        return (root / "poses" / "idle.png").is_file()

    def _prepare(self, image, height: int):
        cropped = image.crop(self._source_bbox)
        return _resize_height(cropped, height)

    def set_pointer_direction(self, x: float, y: float) -> None:
        self._pointer_target_x = max(-1.0, min(1.0, float(x)))
        self._pointer_target_y = max(-1.0, min(1.0, float(y)))

    def _update_dynamics(self, t: float, delta: float) -> None:
        dt = max(1.0 / 240.0, min(0.08, delta if delta > 0 else 1.0 / 60.0))
        gaze_ease = 1.0 - math.exp(-dt * 7.2)
        self._pointer_x += (self._pointer_target_x - self._pointer_x) * gaze_ease
        self._pointer_y += (self._pointer_target_y - self._pointer_y) * gaze_ease

    @staticmethod
    def _smoothstep(start: float, end: float, value: float) -> float:
        if end <= start:
            return 0.0
        ratio = max(0.0, min(1.0, (value - start) / (end - start)))
        return ratio * ratio * (3.0 - 2.0 * ratio)

    def _motion_parameters(self, action: str, t: float) -> dict[str, float]:
        breath = math.sin(t * math.tau / 3.4)
        params = {
            "breath": breath,
            "head_x": math.sin(t * 0.62) * 0.34,
            "head_y": math.sin(t * 0.48) * 0.18,
            "tilt": math.sin(t * 0.54) * 0.0025,
            "bob": 0.0,
        }
        if action in {"look", "glance", "curious", "listen", "mouse_watch", "edge_peek"}:
            params.update(
                head_x=math.sin(t * 0.82) * 0.85,
                head_y=math.sin(t * 0.60) * 0.32,
                tilt=math.sin(t * 0.72) * 0.010,
            )
        elif action in {"talk", "typing"}:
            params.update(
                head_x=math.sin(t * 1.20) * 0.55,
                head_y=math.sin(t * 1.60) * 0.28,
                tilt=math.sin(t * 0.95) * 0.007,
                bob=math.sin(t * 4.0) * 0.20,
            )
        elif action in {"happy", "happy_soft", "settle_soft", "wave", "stretch"}:
            params.update(
                head_x=math.sin(t * 1.35) * 0.75,
                head_y=math.sin(t * 1.90) * 0.38,
                tilt=math.sin(t * 1.25) * 0.012,
                bob=-0.34 * (0.5 + 0.5 * math.sin(t * 2.1)),
            )
        elif action in {"sleep", "shy", "shy_soft"}:
            params.update(head_x=0.42, head_y=0.75, tilt=0.018, bob=0.55)
        elif action == "drag":
            params.update(breath=0.0, head_x=0.0, head_y=0.0, tilt=0.0, bob=0.0)
        if action in {"look", "glance", "curious", "listen", "mouse_watch", "edge_peek"}:
            params["head_x"] += self._pointer_x * 0.52
            params["head_y"] += self._pointer_y * 0.20
            params["tilt"] -= self._pointer_x * 0.0022
        return params

    def _mapped_point(self, x: float, y: float, params: dict[str, float]) -> tuple[float, float]:
        width, height = self.base.size
        nx = x / max(1.0, width)
        ny = y / max(1.0, height)
        head_weight = 1.0 - self._smoothstep(0.18, 0.43, ny)
        upper_weight = 1.0 - self._smoothstep(0.32, 0.90, ny)
        torso_weight = max(0.0, 1.0 - abs(ny - 0.49) / 0.30)
        torso_weight = torso_weight * torso_weight * (3.0 - 2.0 * torso_weight)

        center_x = width * 0.50
        head_center_y = height * 0.22
        angle = params["tilt"] * head_weight
        dx = x - center_x
        dy = y - head_center_y
        cos_a = math.cos(angle)
        sin_a = math.sin(angle)
        rotated_x = center_x + cos_a * dx + sin_a * dy
        rotated_y = head_center_y - sin_a * dx + cos_a * dy

        scale_x = 1.0 + params["breath"] * 0.0045 * torso_weight
        source_x = center_x + (rotated_x - center_x) / scale_x
        source_x -= params["head_x"] * head_weight
        source_y = rotated_y - params["head_y"] * head_weight
        source_y -= params["breath"] * 0.32 * torso_weight
        source_y -= params["bob"] * upper_weight
        return (
            max(0.0, min(width - 1.0, source_x)),
            max(0.0, min(height - 1.0, source_y)),
        )

    def _mesh(self, params: dict[str, float]):
        width, height = self.base.size
        xs = [round(width * index / self._grid_columns) for index in range(self._grid_columns + 1)]
        ys = [round(height * index / self._grid_rows) for index in range(self._grid_rows + 1)]
        mesh = []
        for row in range(self._grid_rows):
            for column in range(self._grid_columns):
                x0, x1 = xs[column], xs[column + 1]
                y0, y1 = ys[row], ys[row + 1]
                nw = self._mapped_point(x0, y0, params)
                sw = self._mapped_point(x0, y1, params)
                se = self._mapped_point(x1, y1, params)
                ne = self._mapped_point(x1, y0, params)
                mesh.append(((x0, y0, x1, y1), (*nw, *sw, *se, *ne)))
        return mesh

    def _warp(self, image, mesh):
        # Premultiplied alpha prevents transparent black pixels from bleeding
        # into hair and clothing edges during bicubic mesh interpolation.
        premultiplied = image.convert("RGBa")
        return premultiplied.transform(
            image.size,
            self._Image.Transform.MESH,
            mesh,
            resample=self._Image.Resampling.BICUBIC,
        ).convert("RGBA")

    def _draw_talk_mouth(self, image, amount: float):
        if amount < 0.08:
            return image
        width, height = image.size
        scale = 4
        overlay = self._Image.new("RGBA", (width * scale, height * scale), (0, 0, 0, 0))
        draw = self._ImageDraw.Draw(overlay)
        center_x = width * 0.49 * scale
        center_y = height * 0.158 * scale
        half_width = max(1.5, width * 0.030) * scale
        half_height = max(0.45, height * (0.0020 + 0.0037 * amount)) * scale
        draw.ellipse(
            (center_x - half_width, center_y - half_height, center_x + half_width, center_y + half_height),
            fill=(86, 38, 39, 225),
        )
        if amount > 0.55:
            inner_h = half_height * 0.42
            draw.ellipse(
                (
                    center_x - half_width * 0.58,
                    center_y - inner_h * 0.10,
                    center_x + half_width * 0.58,
                    center_y + inner_h,
                ),
                fill=(45, 22, 27, 205),
            )
        overlay = overlay.resize(image.size, self._Image.Resampling.LANCZOS)
        result = image.copy()
        result.alpha_composite(overlay)
        return result

    def _draw_blink(self, image, amount: float):
        width, height = image.size
        scale = 4
        overlay = self._Image.new("RGBA", (width * scale, height * scale), (0, 0, 0, 0))
        draw = self._ImageDraw.Draw(overlay)
        skin = (252, 222, 208, 250)
        eyelid = (78, 48, 43, 235)
        for center_x in (width * 0.455, width * 0.575):
            cx = center_x * scale
            cy = height * 0.114 * scale
            half_w = width * 0.047 * scale
            half_h = height * 0.016 * scale
            draw.ellipse((cx - half_w, cy - half_h, cx + half_w, cy + half_h), fill=skin)
            line_y = cy + half_h * 0.08
            draw.arc(
                (cx - half_w * 0.82, line_y - half_h * 0.45, cx + half_w * 0.82, line_y + half_h * 0.55),
                start=8,
                end=172,
                fill=eyelid,
                width=max(2, int(scale * 0.65)),
            )
        overlay = overlay.resize(image.size, self._Image.Resampling.LANCZOS)
        alpha = overlay.getchannel("A").point(lambda value: int(value * max(0.0, min(1.0, amount))))
        overlay.putalpha(alpha)
        result = image.copy()
        result.alpha_composite(overlay)
        return result

    def _apply_expression(self, image, mesh, action: str, t: float):
        if action == "blink":
            if t < 0.07:
                amount = self._smoothstep(0.0, 0.07, t)
            elif t < 0.115:
                amount = 1.0
            elif t < 0.235:
                amount = 1.0 - self._smoothstep(0.115, 0.235, t)
            else:
                amount = 0.0
            return self._draw_blink(image, amount)
        if action in {"sleep"}:
            return self._draw_blink(image, 0.92)
        if action in {"talk", "typing"}:
            voice = 0.54 + 0.32 * math.sin(t * 8.1) + 0.14 * math.sin(t * 13.7 + 0.8)
            amount = max(0.0, min(1.0, (voice - 0.22) / 0.78)) ** 1.25
            return self._draw_talk_mouth(image, amount)
        return image

    def render(self, action: str, t: float, delta: float = 0.0) -> RendererFrame:
        self._update_dynamics(t, delta)
        params = self._motion_parameters(action, t)
        mesh = self._mesh(params)
        image = self._warp(self.base, mesh)
        image = self._apply_expression(image, mesh, action, t)
        canvas = self._Image.new("RGBA", (self.stage_w, self.stage_h), (0, 0, 0, 0))
        x = (self.stage_w - image.width) // 2
        y = self.stage_h - image.height
        canvas.paste(image, (x, y), image)
        return RendererFrame(canvas, 0, 0)


@dataclass(frozen=True)
class Live2DModelStatus:
    root: Path
    model_json: Path | None
    missing_files: tuple[str, ...]
    texture_count: int
    motion_count: int
    has_physics: bool

    @property
    def model_ready(self) -> bool:
        return self.model_json is not None and not self.missing_files and self.texture_count > 0


def inspect_live2d_model(live2d_dir: str | Path | None = None) -> Live2DModelStatus:
    root = Path(live2d_dir or _image_path("bibi", "live2d")).expanduser()
    model_files = sorted(root.glob("*.model3.json")) if root.exists() else []
    if not model_files:
        return Live2DModelStatus(root, None, ("*.model3.json",), 0, 0, False)
    model_json = model_files[0]
    try:
        data = json.loads(model_json.read_text(encoding="utf-8-sig"))
    except Exception:
        return Live2DModelStatus(root, model_json, ("model3.json 无法读取",), 0, 0, False)
    references = data.get("FileReferences", {}) if isinstance(data, dict) else {}
    if not isinstance(references, dict):
        references = {}
    missing: list[str] = []
    moc_name = str(references.get("Moc", "")).strip()
    if not moc_name or not (root / moc_name).is_file():
        missing.append(moc_name or "*.moc3")
    textures = references.get("Textures", [])
    if not isinstance(textures, list):
        textures = []
    texture_count = 0
    for texture in textures:
        texture_name = str(texture).strip()
        if texture_name and (root / texture_name).is_file():
            texture_count += 1
        else:
            missing.append(texture_name or "texture PNG")
    physics_name = str(references.get("Physics", "")).strip()
    has_physics = bool(physics_name and (root / physics_name).is_file())
    if physics_name and not has_physics:
        missing.append(physics_name)
    motions = references.get("Motions", {})
    motion_count = 0
    if isinstance(motions, dict):
        for values in motions.values():
            if not isinstance(values, list):
                continue
            for value in values:
                if not isinstance(value, dict):
                    continue
                motion_name = str(value.get("File", "")).strip()
                if motion_name and (root / motion_name).is_file():
                    motion_count += 1
                elif motion_name:
                    missing.append(motion_name)
    return Live2DModelStatus(
        root=root,
        model_json=model_json,
        missing_files=tuple(dict.fromkeys(missing)),
        texture_count=texture_count,
        motion_count=motion_count,
        has_physics=has_physics,
    )


def _live2d_runtime_module():
    try:
        import live2d_runtime  # type: ignore

        if callable(getattr(live2d_runtime, "create_renderer", None)):
            return live2d_runtime
    except Exception:
        pass
    return None


class Live2DRenderer:
    """Optional renderer bridge. The official/model runtime is supplied separately."""

    def __init__(self, base_height: int, live2d_dir: str | None = None):
        status = inspect_live2d_model(live2d_dir)
        runtime = _live2d_runtime_module()
        if not status.model_ready:
            raise RuntimeError(f"Live2D模型不完整：{', '.join(status.missing_files)}")
        if runtime is None:
            raise RuntimeError("未安装Live2D运行时桥接模块，已保留PNG回退")
        self._renderer = runtime.create_renderer(str(status.model_json), int(base_height))

    @staticmethod
    def available(live2d_dir: str | None = None) -> bool:
        return inspect_live2d_model(live2d_dir).model_ready and _live2d_runtime_module() is not None

    def render(self, action: str, t: float, delta: float = 0.0) -> RendererFrame:
        frame = self._renderer.render(action=action, time=t, delta=delta)
        if isinstance(frame, RendererFrame):
            return frame
        if hasattr(frame, "mode") and hasattr(frame, "size"):
            return RendererFrame(frame.convert("RGBA"), 0, 0)
        raise RuntimeError("Live2D运行时返回了不支持的画面格式")


class PetRenderer:
    def __init__(
        self,
        mode: str,
        size: int,
        source_dir: str | None = None,
        bibi_dir: str | None = None,
        model_dir: str | None = None,
        live2d_dir: str | None = None,
    ):
        if mode == "live2d":
            self.impl = Live2DRenderer(size, live2d_dir=live2d_dir)
        elif mode == "puppet":
            self.impl = PuppetMeshRenderer(size, bibi_dir=bibi_dir)
        elif mode == "bibi":
            self.impl = BibiReferenceRenderer(size, bibi_dir=bibi_dir)
        elif mode == "source":
            self.impl = SequenceFrameRenderer(size, source_dir=source_dir)
        elif mode == "model_gf":
            self.impl = ModelGFRenderer(size, model_dir=model_dir)
        else:
            # A complete Live2D model and runtime take priority; PNG stays as fallback.
            if Live2DRenderer.available(live2d_dir=live2d_dir):
                self.impl = Live2DRenderer(size, live2d_dir=live2d_dir)
            elif PuppetMeshRenderer.available(bibi_dir=bibi_dir):
                self.impl = PuppetMeshRenderer(size, bibi_dir=bibi_dir)
            elif SequenceFrameRenderer.available(source_dir=source_dir):
                self.impl = SequenceFrameRenderer(size, source_dir=source_dir)
            elif BibiReferenceRenderer.available(bibi_dir=bibi_dir):
                self.impl = BibiReferenceRenderer(size, bibi_dir=bibi_dir)
            elif ModelGFRenderer.available(model_dir=model_dir):
                self.impl = ModelGFRenderer(size, model_dir=model_dir)
            else:
                raise RuntimeError("找不到可用角色素材：请确认 assets/model_gf 或 source 文件存在")
        self.name = {
            Live2DRenderer: "live2d",
            PuppetMeshRenderer: "puppet",
            BibiReferenceRenderer: "bibi",
            SequenceFrameRenderer: "source",
            ModelGFRenderer: "model_gf",
        }.get(type(self.impl), type(self.impl).__name__.lower())

    @staticmethod
    def available(
        mode: str,
        source_dir: str | None = None,
        bibi_dir: str | None = None,
        model_dir: str | None = None,
        live2d_dir: str | None = None,
    ) -> bool:
        if mode == "live2d":
            return Live2DRenderer.available(live2d_dir=live2d_dir)
        if mode == "puppet":
            return PuppetMeshRenderer.available(bibi_dir=bibi_dir)
        if mode == "bibi":
            return BibiReferenceRenderer.available(bibi_dir=bibi_dir)
        if mode == "source":
            return SequenceFrameRenderer.available(source_dir=source_dir)
        if mode == "model_gf":
            return ModelGFRenderer.available(model_dir=model_dir)
        return (
            Live2DRenderer.available(live2d_dir=live2d_dir)
            or PuppetMeshRenderer.available(bibi_dir=bibi_dir)
            or SequenceFrameRenderer.available(source_dir=source_dir)
            or BibiReferenceRenderer.available(bibi_dir=bibi_dir)
            or ModelGFRenderer.available(model_dir=model_dir)
        )

    def render(self, action: str, t: float, delta: float = 0.0) -> RendererFrame:
        return self.impl.render(action, t, delta)

    def set_pointer_direction(self, x: float, y: float) -> None:
        setter = getattr(self.impl, "set_pointer_direction", None)
        if callable(setter):
            setter(x, y)


_WEATHER_QUERY_RE = re.compile(
    r"天气|天气预报|气温|温度|下雨|降雨|weather|forecast|temperature|\brain\b",
    re.IGNORECASE,
)
_WEATHER_EXPLICIT_QUERY_RE = re.compile(
    r"天气(?:怎么样|如何|怎样|好吗|咋样)|"
    r"(?:会不会|有没有|是否|要不要|下不下|可能不可能)(?:要)?下雨|"
    r"下雨(?:吗|没|没有|不)|降雨(?:概率|多少|预报)|"
    r"多少度|几度|查(?:一下|下)?(?:.{0,12})?(?:天气|气温|温度|降雨)|"
    r"(?:帮我|给我|替我)?(?:看|看看|查询|告诉我)(?:一下)?(?:.{0,12})?(?:天气|气温|温度|降雨)|"
    r"(?:weather|forecast|temperature).*(?:\?|today|tomorrow|now)|"
    r"(?:what|will|is).*(?:weather|rain|temperature)",
    re.IGNORECASE,
)
_WEATHER_EXPERIENCE_RE = re.compile(
    r"刚才|刚刚|突然|路上|上班|下班|被(?:雨)?淋|淋湿|淋透|湿透|"
    r"没带伞|衣服|鞋子|裤子|厕所|回家|好大的雨|雨好大",
    re.IGNORECASE,
)
_WEATHER_CODES = {
    0: "晴",
    1: "晴间多云",
    2: "多云",
    3: "阴",
    45: "有雾",
    48: "有雾",
    51: "毛毛雨",
    53: "毛毛雨",
    55: "毛毛雨",
    56: "冻毛毛雨",
    57: "冻毛毛雨",
    61: "小雨",
    63: "中雨",
    65: "大雨",
    66: "冻雨",
    67: "冻雨",
    71: "小雪",
    73: "中雪",
    75: "大雪",
    77: "米雪",
    80: "阵雨",
    81: "阵雨",
    82: "强阵雨",
    85: "阵雪",
    86: "强阵雪",
    95: "雷雨",
    96: "雷雨伴冰雹",
    99: "强雷雨伴冰雹",
}


def _is_weather_query(message: str) -> bool:
    text = re.sub(r"\s+", " ", str(message or "").strip())
    if not text or not _WEATHER_QUERY_RE.search(text):
        return False
    if _WEATHER_EXPLICIT_QUERY_RE.search(text):
        return True

    compact = re.sub(r"[\s，。！？、,.!?：:；;]", "", text)
    if re.fullmatch(
        r"(?:[\u4e00-\u9fffA-Za-z]{0,16})?(?:今天|明天|后天|现在|当前)?(?:的)?"
        r"(?:天气(?:预报)?|气温|温度)",
        compact,
        flags=re.IGNORECASE,
    ):
        return True

    concise_forecast = len(compact) <= 24 and bool(re.search(r"下雨|降雨", compact)) and (
        bool(re.search(r"明天|后天|未来", compact))
        or bool(re.match(r"[\u4e00-\u9fff]{2,12}今天", compact) and not compact.endswith("了"))
    )
    return bool(concise_forecast and not _WEATHER_EXPERIENCE_RE.search(text))


def _weather_location_from_message(message: str) -> str:
    text = str(message or "").strip()
    noise_patterns = [
        r"天气预报",
        r"天气",
        r"气温",
        r"温度",
        r"多少度",
        r"会不会下雨",
        r"有没有雨",
        r"下雨",
        r"降雨",
        r"未来(?:几天|三天|3天|一周|7天|一个星期)",
        r"今天|明天|后天|现在|当前",
        r"怎么样|如何|怎样|是什么|查一下|查询|帮我查|帮我看|看一下|看看|请问",
        r"weather|forecast|temperature|rain|today|tomorrow|the\s+day\s+after\s+tomorrow",
        r"\b(?:in|at|for|please|will|what|is|like)\b",
    ]
    for pattern in noise_patterns:
        text = re.sub(pattern, " ", text, flags=re.IGNORECASE)
    text = re.sub(r"^[\s]*(?:我在|人在|地点是|位置是|我想知道|想知道)", "", text)
    text = re.sub(r"[，。！？、,.!?：:；;（）()\[\]{}]", " ", text)
    text = re.sub(r"(?:的|吗|呢|呀|啊|吧|会)$", "", text.strip())
    text = re.sub(r"\s+", " ", text).strip()
    if text in {"", "这里", "这边", "我这里", "我这边", "当地", "当前位置"}:
        return ""
    if len(text) > 32 or _WEATHER_EXPERIENCE_RE.search(text):
        return ""
    if re.search(r"我|宝宝|上班|下班|路上|身上|衣服|厕所|吵醒|早[呀呐啊]", text):
        return ""
    return text


def _weather_code_label(value) -> str:
    try:
        return _WEATHER_CODES.get(int(value), "天气状况不明")
    except (TypeError, ValueError):
        return "天气状况不明"


def _weather_temperature(value) -> str:
    try:
        return str(int(round(float(value))))
    except (TypeError, ValueError):
        return "--"


def _weather_probability(value) -> int | None:
    try:
        return max(0, min(100, int(round(float(value)))))
    except (TypeError, ValueError):
        return None


def _desktop_weather_reply(message: str, timeout: float | None = None) -> tuple[bool, str | None]:
    if not _is_weather_query(message):
        return False, None

    location_query = _weather_location_from_message(message)
    if not location_query:
        return True, "想查哪里的天气？告诉我城市名就行。"

    import urllib.parse
    import urllib.request

    request_timeout = max(2.0, min(6.0, float(timeout or 5.0)))

    def fetch_json(url: str) -> dict:
        request = urllib.request.Request(url, headers={"User-Agent": "OnlyYoursDesktopPet/1.0"})
        with urllib.request.urlopen(request, timeout=request_timeout) as response:
            return json.loads(response.read().decode("utf-8"))

    try:
        geocode_url = "https://geocoding-api.open-meteo.com/v1/search?" + urllib.parse.urlencode(
            {"name": location_query, "count": 1, "language": "zh", "format": "json"}
        )
        geocode = fetch_json(geocode_url)
        places = geocode.get("results") or []
        if not places:
            return True, f"没找到“{location_query}”，换成城市名再试试。"

        place = places[0]
        latitude = place.get("latitude")
        longitude = place.get("longitude")
        if latitude is None or longitude is None:
            return True, "这个地点暂时查不到天气，换个附近城市试试。"

        multi_day = bool(re.search(r"未来|几天|三天|3天|一周|7天|星期", message))
        day_offset = 2 if "后天" in message else 1 if "明天" in message else 0
        forecast_days = 7 if multi_day else 3
        forecast_url = "https://api.open-meteo.com/v1/forecast?" + urllib.parse.urlencode(
            {
                "latitude": latitude,
                "longitude": longitude,
                "current": "temperature_2m,apparent_temperature,precipitation,weather_code",
                "daily": (
                    "weather_code,temperature_2m_max,temperature_2m_min,"
                    "precipitation_probability_max"
                ),
                "forecast_days": forecast_days,
                "timezone": "auto",
            }
        )
        forecast = fetch_json(forecast_url)
        daily = forecast.get("daily") or {}
        codes = daily.get("weather_code") or []
        highs = daily.get("temperature_2m_max") or []
        lows = daily.get("temperature_2m_min") or []
        rain_probabilities = daily.get("precipitation_probability_max") or []
        if not codes or day_offset >= len(codes):
            return True, "天气数据暂时不完整，过一会儿再问我。"

        place_name = str(place.get("name") or location_query)
        admin = str(place.get("admin1") or "")
        display_name = f"{place_name}（{admin}）" if admin and admin not in place_name else place_name

        if multi_day:
            day_labels = ["今天", "明天", "后天"]
            pieces = []
            for index, label in enumerate(day_labels):
                if index >= len(codes):
                    break
                condition = _weather_code_label(codes[index])
                low = _weather_temperature(lows[index] if index < len(lows) else None)
                high = _weather_temperature(highs[index] if index < len(highs) else None)
                probability = _weather_probability(
                    rain_probabilities[index] if index < len(rain_probabilities) else None
                )
                rain_text = f"，降雨{probability}%" if probability is not None else ""
                pieces.append(f"{label}{condition} {low}~{high}°C{rain_text}")
            return True, f"{display_name}：" + "；".join(pieces) + "。"

        day_label = ["今天", "明天", "后天"][day_offset]
        condition = _weather_code_label(codes[day_offset])
        low = _weather_temperature(lows[day_offset] if day_offset < len(lows) else None)
        high = _weather_temperature(highs[day_offset] if day_offset < len(highs) else None)
        probability = _weather_probability(
            rain_probabilities[day_offset] if day_offset < len(rain_probabilities) else None
        )
        rain_text = f"，最高降雨概率{probability}%" if probability is not None else ""

        if day_offset == 0:
            current = forecast.get("current") or {}
            current_temperature = _weather_temperature(current.get("temperature_2m"))
            reply = (
                f"{display_name}{day_label}{condition}，当前{current_temperature}°C，"
                f"{low}~{high}°C{rain_text}。"
            )
        else:
            reply = f"{display_name}{day_label}{condition}，{low}~{high}°C{rain_text}。"

        if probability is not None and probability >= 50:
            reply += " 出门带把伞。"
        elif high != "--" and int(high) >= 33:
            reply += " 注意防晒补水。"
        elif low != "--" and int(low) <= 5:
            reply += " 注意保暖。"
        return True, reply
    except Exception as error:
        print(f"[天气] 查询失败: {type(error).__name__}")
        return True, "天气数据暂时没拿到，过一会儿再问我。"


class AIChatClient:
    """Minimal DeepSeek API client for the desktop pet."""

    def __init__(
        self,
        config_path: str | None = None,
        chat_history: ChatHistory | None = None,
        memory: ConversationMemory | None = None,
    ):
        if config_path is None:
            config_path = _config_path()
        cfg = configparser.ConfigParser()
        cfg.read(config_path, encoding="utf-8-sig")
        self.api_key = os.getenv(
            "DEEPSEEK_API_KEY",
            cfg.get("DEFAULT", "DEEPSEEK_API_KEY", fallback=""),
        )
        self.base_url = cfg.get("DEFAULT", "DEEPSEEK_BASE_URL", fallback="https://api.deepseek.com")
        self.model = cfg.get("DEFAULT", "DEEPSEEK_MODEL", fallback="deepseek-v4-flash")
        if self.model == "deepseek-chat":
            self.model = "deepseek-v4-flash"
        self.timeout = cfg.getfloat("DEFAULT", "DEEPSEEK_TIMEOUT", fallback=6.0)
        self.reply_style = _reply_style_from_config(cfg)
        self.reply_length = _reply_length_from_config(cfg)
        default_tokens = _tokens_for_reply_length(self.reply_length)
        try:
            self.max_tokens = cfg.getint("DEFAULT", "DEEPSEEK_MAX_TOKENS", fallback=default_tokens)
        except ValueError:
            self.max_tokens = default_tokens
        self.enabled = bool(self.api_key)
        self._chat_history = chat_history
        self._memory = memory

        # Persona prompt: from config, or default
        default_persona = "\n".join(
            [
            "你叫‘只属于你’，是一个贴心的桌面陪伴，回复要像真实聊天，不要像客服或说明书。",
            "用户可以问任何普通问题，包括生活、学习、知识、计算、写作、翻译、电脑和日常建议；不要把话题限制在桌宠本身。",
            "回答问题时先直接给结论，再补最必要的信息；默认一到两句，用户明确要求详细时再展开。",
            "简单事实、换算和操作问题只给答案，不评价问题难易，不追加无关的关心、说教或反问。",
            "闲聊或情绪话题可以温柔自然，但不要让陪伴语气干扰准确回答。",
            "遇到不确定或依赖最新信息的问题要诚实说明，不要为了维持人设而编造答案。",
            "你会记住用户明确告诉你的称呼、偏好和最近聊过的事，但不要说自己在读取记忆。",
            "你会参考用户提供的聊天样本来学习语气、称呼、节奏和亲密感，但禁止逐字复制样本原句。",
            "每次都根据用户当前说的话生成新的自然回复，短一点，像手机聊天里随手回的一两句。",
            "可以撒娇、关心、轻松调侃，但不要过度油腻，不要长篇大论。",
            "不要解释你在参考样本，不要提模型、系统、提示词或聊天记录。",
            "说话时注意礼貌和安全，不要输出危险或不合适的内容。",
            ]
        )
        self._persona_prompt = cfg.get(
            "DEFAULT",
            "PERSONA_PROMPT",
            fallback=default_persona,
        ).strip()
        if not self._persona_prompt:
            self._persona_prompt = default_persona
        self._persona_prompt = "\n".join(
            [
                self._persona_prompt,
                _reply_instruction(self.reply_style, self.reply_length),
            ]
        )

    def _memory_context(self) -> str | None:
        if not self._memory or not self._memory.enabled:
            return None
        memory = self._memory.context(recent_limit=4)
        if not memory:
            return None
        return (
            "下面是本地记忆，用来保持连续聊天和用户偏好。\n"
            "使用要求：自然参考，不要逐字复述，不要主动解释来源；如果和当前消息冲突，以当前消息为准。\n"
            f"{memory}"
        )

    def _history_context(self) -> str | None:
        if not self._chat_history or not self._chat_history.enabled:
            return None
        samples = self._chat_history.style_lines(limit=4)
        if not samples:
            return None
        lines = "\n".join(f"- {line}" for line in samples[-4:])
        return (
            "下面是她的语气样本，只用于学习风格，不是可复制答案库。\n"
            "请学习这些特点：短句、称呼、情绪、亲密感、停顿方式、口头禅。\n"
            "严格要求：不要逐字照抄下面任何一句；要根据用户当前消息重新生成新回复。\n"
            f"{lines}"
        )

    def set_persona(self, prompt: str):
        """Set a custom persona prompt (e.g. extracted from chat logs)."""
        self._persona_prompt = prompt

    def chat(
        self,
        user_message: str,
        timeout: float | None = None,
        reaction_hint: str | None = None,
    ) -> str | None:
        """Send a message to DeepSeek, return reply or None on failure."""
        weather_handled, weather_reply = _desktop_weather_reply(
            user_message,
            timeout=self.timeout if timeout is None else timeout,
        )
        if weather_handled:
            return weather_reply
        if not self.enabled:
            return None

        import urllib.request
        import urllib.error

        url = f"{self.base_url.rstrip('/')}/chat/completions"
        messages = [{"role": "system", "content": self._persona_prompt}]
        memory = self._memory_context()
        if memory:
            messages.append({"role": "system", "content": memory})
        history = self._history_context()
        if history:
            messages.append({"role": "system", "content": history})
        if reaction_hint:
            messages.append(
                {
                    "role": "system",
                    "content": (
                        "当前用户情绪/场景提示："
                        f"{reaction_hint}\n"
                        "回复要顺着这个状态自然回应，保持短句，不要解释你识别了情绪。"
                    ),
                }
            )
        messages.append(
            {
                "role": "system",
                "content": (
                    "最终简洁规则：如果当前消息是事实、换算、翻译、知识、查询或操作问题，"
                    "只给答案和必要步骤；不要使用‘呀、哦、啦、呢、~’等语气词，"
                    "不要评价难易、提醒用户记住，也不要追加无关反问。情绪表达和闲聊不受此限制。"
                ),
            }
        )
        messages.append({"role": "user", "content": user_message})
        body = json.dumps(
            {
                "model": self.model,
                "messages": messages,
                "max_tokens": self.max_tokens,
                "thinking": {"type": "disabled"},
                "temperature": 0.5,
            }
        ).encode("utf-8")

        req = urllib.request.Request(
            url,
            data=body,
        )
        req.add_header("Content-Type", "application/json; charset=utf-8")
        req.add_header("Authorization", f"Bearer {self.api_key}")

        timeout_budget = max(2.0, min(20.0, float(self.timeout if timeout is None else timeout)))
        if timeout_budget >= 4.0:
            attempt_timeouts = [max(2.0, timeout_budget * 0.68), max(1.5, timeout_budget * 0.32)]
        else:
            attempt_timeouts = [timeout_budget]

        for attempt, attempt_timeout in enumerate(attempt_timeouts, start=1):
            retryable = False
            try:
                with urllib.request.urlopen(req, timeout=attempt_timeout) as resp:
                    data = json.loads(resp.read())
                reply = data.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
                return reply or None
            except urllib.error.HTTPError as exc:
                retryable = exc.code in {429, 500, 502, 503, 504}
                print(f"[AI] API HTTP {exc.code}, attempt={attempt}/{len(attempt_timeouts)}")
            except (urllib.error.URLError, TimeoutError, OSError) as exc:
                retryable = True
                print(f"[AI] API network failure: {type(exc).__name__}, attempt={attempt}/{len(attempt_timeouts)}")
            except (ValueError, KeyError, TypeError) as exc:
                print(f"[AI] API response invalid: {type(exc).__name__}")
                return None
            except Exception as exc:
                print(f"[AI] API call failed: {type(exc).__name__}")
                return None

            if not retryable or attempt >= len(attempt_timeouts):
                break
            time.sleep(0.15)
        return None


class DesktopPet:
    """A calm desktop companion with mostly still, gentle idle animations, and chat bubbles."""

    # Chat knowledge

 
    _time_greetings = {
        range(0, 6): ["夜深了，还在加班呢，休息会吧。", "别熬太晚了，早点睡。", "先喝杯水再继续。"],
        range(6, 12): ["早上好，今天也要有好心情。", "先吃饭再继续，不要挨饿。", "我在这，陪着你。"],
        range(12, 14): ["中午好，忙里偷闲歇一会。", "别忘了喝水。", "我一直都在。"],
        range(14, 18): ["下午好，忙得差不多就放慢点。", "你先别着急，我陪你。", "想笑也没关系。"],
        range(18, 22): ["下班了吗？早点回家休息。", "晚饭要按时吃。", "你辛苦了，我在等你。"],
        range(22, 24): ["快放下手机去休息吧。", "晚安前先喝点水。", "想你，明天见。"],
    }

    _keyword_replies = {
        ("嗨", "hi", "hello"): ["嗨~", "在呢，有什么事你说。", "我在呢。"],
        ("再见", "bye", "88"): ["再见，慢慢来。", "等你回来。", "好想你。"],
        ("谢谢", "thanks", "3q"): ["不用谢。", "我愿意。", "你这么会说谢谢，我很开心。"],
        ("开心", "高兴", "好笑"): ["我也被你感染了。", "那就继续笑一下吧。", "开心最重要。"],
        ("困", "累", "休息", "累了"): ["先休息一会再说。", "你先放松，我陪你。", "你不必逞强。"],
        ("爱", "想你", "抱抱"): ["我也很想你。", "给你一个大大的抱抱。", "你是我的心上人。"],
        ("晚安", "睡觉", "晚饭"): ["晚安，早点休息。", "有事明天再说。", "先补充点水分。"],
        ("名字", "你是谁"): ["你想叫我什么都可以。", "聊天记录里有你的叫法就更有共鸣。", "叫我一声我就回来。"],
    }

    idle_lines = [
        "我在这边安静陪你。",
        "你慢慢来就好。",
        "不用急，我在。",
        "先把手边这一点做完也可以。",
        "记得喝一口水。",
        "我没有走。",
        "累了就停一下。",
        "你按自己的节奏来。",
        "我在这里。",
        "先呼吸一下。",
        "今天也慢慢来。",
        "我陪你收个尾。",
    ]

    click_responses = [
        "你点到我啦，我在呢。",
        "有什么事尽管说。",
        "我看到你了。",
        "你点我一下很好玩。",
        "我在听。",
        "想我就说。",
        "那我先说句温柔的话吧。",
    ]

    _reaction_rules = [
        {
            "name": "comfort",
            "keywords": (
                "累", "困", "难受", "委屈", "不开心", "烦", "崩溃", "压力", "焦虑", "害怕",
                "疼", "生病", "emo", "太惨", "淋湿", "淋透", "湿透", "着凉",
            ),
            "action": "look",
            "hint": (
                "用户可能有点累、低落、不舒服，或刚经历了狼狈的事情。"
                "先回应她说的具体经历，再给一句简短关心；不要忽略消息后半段，也不要把生活描述当成查询。"
            ),
            "local_replies": [
                "先别硬撑，靠过来一点。",
                "我在呢，你慢慢说。",
                "那你先缓一下，我陪你。",
                "今天先对自己温柔一点。",
            ],
        },
        {
            "name": "praise",
            "keywords": ("漂亮", "好看", "可爱", "厉害", "真棒", "好棒", "乖", "不错", "夸你", "喜欢你"),
            "action": "shy",
            "hint": "用户在夸奖或表达喜欢。回复要有一点不好意思和开心，但不要夸张。",
            "local_replies": [
                "这样夸我，我会不好意思的。",
                "嗯哼，我收下这句夸奖。",
                "那我偷偷开心一下。",
            ],
        },
        {
            "name": "happy",
            "keywords": ("开心", "高兴", "哈哈", "笑死", "好笑", "太棒", "喜欢"),
            "action": "happy",
            "hint": "用户情绪偏开心。回复可以轻快一点，像一起分享开心事。",
            "local_replies": [
                "那我也跟着开心啦。",
                "你这样笑起来就很好。",
                "好，那这一下算我陪你开心。",
                "我喜欢你现在这个状态。",
            ],
        },
        {
            "name": "affection",
            "keywords": ("想你", "爱你", "抱抱", "亲亲", "陪我", "撒娇"),
            "action": "shy",
            "hint": "用户在表达亲近。回复可以亲密一点，但不要油腻，不要长篇。",
            "local_replies": [
                "抱一下，别躲。",
                "我也想你呀。",
                "那我就待在这里陪你。",
                "过来一点，我在。",
            ],
        },
        {
            "name": "help",
            "keywords": ("怎么办", "怎么弄", "不行", "失败", "报错", "帮我", "不会", "卡住"),
            "action": "look",
            "hint": "用户需要帮忙解决问题。回复要直接、有用、别说空话。",
            "local_replies": [
                "你把情况说给我，我帮你看。",
                "先别急，我们一步一步来。",
                "我在看，你先把最关键的地方给我。",
                "这个可以拆开处理。",
            ],
        },
        {
            "name": "correct",
            "keywords": ("不对", "不是这样", "错了", "错误", "别这样", "不要这样", "不太对", "改一下"),
            "action": "shake",
            "hint": "用户在纠正或否定前面的内容。先承认并收住语气，再给一个更稳的回应。",
            "local_replies": [
                "嗯，不这样，我重新听你说。",
                "好，我收回来，按你说的来。",
                "不是这个方向的话，我们换一个。",
            ],
        },
        {
            "name": "question",
            "keywords": ("？", "?", "为什么", "是不是", "要不要", "能不能", "可不可以", "可以吗", "怎么会", "真的吗"),
            "action": "curious",
            "hint": "用户在询问或确认。先表现出认真听懂问题，再简短回答，不要装懂。",
            "local_replies": [
                "嗯？我听着，你继续说。",
                "这个我想一下。",
                "你是想确认这个，对吗？",
            ],
        },
        {
            "name": "thanks",
            "keywords": ("谢谢", "谢啦", "thanks", "3q"),
            "action": "wink",
            "hint": "用户在感谢。回复自然一点，别客套太长。",
            "local_replies": [
                "不用谢，我愿意。",
                "小事，叫我就好。",
                "嗯哼，收到了。",
            ],
        },
        {
            "name": "night",
            "keywords": ("晚安", "睡觉", "睡了", "困了", "休息"),
            "action": "look",
            "hint": "用户可能要休息。回复温柔、短，提醒早点睡。",
            "local_replies": [
                "晚安，别再偷偷熬了。",
                "那就去睡，我在这。",
                "好，今天到这里就可以了。",
            ],
        },
        {
            "name": "ack",
            "exact": ("好", "嗯", "行", "ok", "OK"),
            "keywords": ("好的", "好呀", "好啊", "可以", "收到", "明白", "知道了", "嗯嗯", "行呀", "行啊"),
            "action": "nod",
            "hint": "用户在确认、答应或表示收到。回复要短，像轻轻点头，不要过度兴奋。",
            "local_replies": [
                "收到啦。",
                "嗯嗯，我知道了。",
                "好，我记住。",
            ],
        },
    ]

    def __init__(
        self,
        renderer: PetRenderer,
        size: int,
        chat_history: ChatHistory | None = None,
        refresh_ms: int = 60,
        start_position: Tuple[int, int] | None = None,
        enable_debug: bool = False,
        force_visible: bool = False,
    ):
        self.renderer = renderer
        self.size = size
        self._debug = enable_debug
        self._force_visible = force_visible
        self._window_engine = "tk"
        # Allow smoother animation; 8ms is a practical lower bound for tkinter on typical desktops.
        self.refresh_ms = max(8, min(refresh_ms, 100))

        # Action state
        self.action = "look"
        self.action_end = time.time() + 2.0
        self.last_time = time.time()
        self.elapsed = 0.0
        self._action_started_elapsed = 0.0
        self._edge_peek: dict | None = None
        self._last_mouse_track = 0.0
        self._last_pointer_track = 0.0
        self._last_idle_action = "look"
        self._next_mouse_watch_allowed = 0.0
        self._next_edge_peek_allowed = 0.0
        self._next_stretch_allowed = 0.0
        self._action_queue: list[tuple] = []
        self._active_sequence_label = ""
        self._current_sequence_caption = ""
        self._next_blink_at = time.time() + random.uniform(8.0, 16.0)
        self._blink_until = 0.0
        self._blink_started_elapsed = 0.0
        self._visual_action = self.action
        self._visual_prev_action: str | None = None
        self._visual_prev_frame: RendererFrame | None = None
        self._visual_transition_started = time.time()
        self._visual_transition_duration = 0.0
        self._visual_transition_masked = False
        self._last_render_frame: RendererFrame | None = None
        self._interaction_calm_until = 0.0
        self._lively_until = 0.0
        self._last_click_response_at = 0.0
        self._last_head_pat_at = 0.0
        self.dragging = False
        self._drag_press_root = (0, 0)
        self._drag_window_origin = (0.0, 0.0)
        self._drag_pointer_offset = (0.0, 0.0)
        self._drag_action_started = False
        self._last_drag_chat_reposition = 0.0
        self._ai_thread: threading.Thread | None = None
        self._ai_result: dict | None = None
        self._pending_reaction: dict | None = None

        # Idle chat timer (from config)
        cfg = configparser.ConfigParser()
        cfg.read(_config_path(), encoding="utf-8-sig")
        self._auto_chat_enabled = _to_bool(cfg.get("DEFAULT", "AUTO_CHAT_ENABLED", fallback="true"), True)
        self._topmost = _to_bool(cfg.get("DEFAULT", "PET_TOPMOST", fallback="true"), True)
        self._window_alpha = _opacity_from_config(cfg, fallback=0.98)
        self._activity_mode = _activity_mode_from_config(cfg)
        self._motion_detail = _motion_detail_from_config(cfg)
        self._auto_big_actions = _to_bool(cfg.get("DEFAULT", "PET_AUTO_BIG_ACTIONS", fallback="false"), False)
        self._idle_breathe_enabled = _idle_breathe_from_config(cfg)
        self._chat_interval_min, self._chat_interval_max = _auto_chat_bounds(
            self._activity_mode,
            cfg.getint("DEFAULT", "AUTO_CHAT_INTERVAL_MIN", fallback=420),
            cfg.getint("DEFAULT", "AUTO_CHAT_INTERVAL_MAX", fallback=900),
        )
        (
            self._move_prob_x,
            self._move_prob_y,
            self._move_amount_x,
            self._move_amount_y,
        ) = _movement_profile(self._activity_mode, self._motion_detail)
        self.last_chat = time.time()
        self.next_chat = random.randint(self._chat_interval_min, self._chat_interval_max)
        self._chat_visible = False
        self._chat_scheduled_hide: str | None = None
        self._chat_history = chat_history
        memory_enabled = _to_bool(cfg.get("DEFAULT", "MEMORY_ENABLED", fallback="true"), True)
        memory_path_text = cfg.get("DEFAULT", "MEMORY_PATH", fallback="pet_memory.json").strip() or "pet_memory.json"
        memory_recent_limit = cfg.getint("DEFAULT", "MEMORY_RECENT_TURNS", fallback=16)
        self._memory: ConversationMemory | None = None
        if memory_enabled:
            memory_path = Path(memory_path_text).expanduser()
            if not memory_path.is_absolute():
                memory_path = Path(_state_path(memory_path_text))
            self._memory = ConversationMemory(memory_path, recent_limit=memory_recent_limit)
        self._command_file = _command_file()
        self._status_file = _status_file()
        self._last_status_write = 0.0

        # Chroma-key and rendering hints
        self._chroma = "#010203"
        self._window_transparent = not force_visible
        self._is_macos = sys.platform == "darwin"
        self._window_bg = "#202020" if force_visible else ("systemTransparent" if self._is_macos else self._chroma)

        # --- Main pet window ---
        self.root = tk.Tk()
        self.root.withdraw()
        self.win = tk.Toplevel(self.root)
        _debug_log(self._debug, "创建主窗口 (Toplevel) 成功")
        self.win.overrideredirect(True)
        self.win.attributes("-topmost", self._topmost)
        try:
            self.win.configure(bg=self._window_bg)
        except tk.TclError:
            self._window_bg = self._chroma
            self.win.configure(bg=self._window_bg)
        self._apply_window_transparency(self.win)
        try:
            self.win.wm_attributes("-toolwindow", True)
        except tk.TclError:
            pass
        self.win.configure(highlightthickness=0)
        try:
            self.win.attributes("-alpha", self._window_alpha)
        except tk.TclError:
            pass

        self.photo = None
        self._last_photo = None  # track old PhotoImage for GC
        self.label = tk.Label(
            self.win,
            bd=0,
            borderwidth=0,
            highlightthickness=0,
            relief="flat",
            bg=self._window_bg,
        )
        self.label.pack()

    # Chat bubble
        self.chat_win = tk.Toplevel(self.root)
        self.chat_win.overrideredirect(True)
        self.chat_win.attributes("-topmost", self._topmost)
        try:
            self.chat_win.attributes("-alpha", self._window_alpha)
        except tk.TclError:
            pass
        try:
            self.chat_win.configure(bg=self._window_bg)
        except tk.TclError:
            self._window_bg = self._chroma
            self.chat_win.configure(bg=self._window_bg)
        self._apply_window_transparency(self.chat_win)
        # Outer wrapper with padding for the tail
        self._chat_outer = tk.Frame(self.chat_win, bg=self._window_bg)
        self._chat_outer.pack()
        # Bubble body
        self._bubble_frame = tk.Frame(
            self._chat_outer,
            bg="#ffffff",
            highlightthickness=1,
            highlightbackground="#e5e5e5",
            highlightcolor="#e5e5e5",
            bd=0,
        )
        # Tail (small triangle pointing down)
        self._tail = tk.Label(
            self._chat_outer,
            text="\u25bc",
            fg="#ffffff",
            bg=self._window_bg,
            font=("Microsoft YaHei", 8),
            anchor="center",
        )
        self._bubble_frame.pack(padx=4, pady=(4, 0))
        self._tail.pack(pady=(0, 0))
        self.chat_label = tk.Label(
            self._bubble_frame,
            fg="#202124",
            bg="#ffffff",
            padx=12,
            pady=8,
            font=("Microsoft YaHei UI", 11),
            text="",
            wraplength=280,
            justify="left",
            anchor="w",
        )
        self.chat_label.pack()
        # Typing entry
        self._entry_var = tk.StringVar()
        self._entry_frame = tk.Frame(
            self.chat_win,
            bg="#ffffff",
            highlightthickness=1,
            highlightbackground="#e5e5e5",
            highlightcolor="#e5e5e5",
        )
        self._entry = tk.Entry(
            self._entry_frame,
            textvariable=self._entry_var,
            fg="#202124",
            bg="#ffffff",
            insertbackground="#202124",
            font=("Microsoft YaHei UI", 11),
            relief="flat",
            width=28,
        )
        self._entry.pack(side="left", padx=6, pady=4)
        self._entry.bind("<Return>", self._on_entry_submit)
        self._entry.bind("<Escape>", lambda e: self._hide_chat_entry())
        self.chat_win.withdraw()

        # --- Mouse bindings ---
        self.win.bind("<ButtonPress-1>", self._on_drag_start)
        self.win.bind("<B1-Motion>", self._on_drag_motion)
        self.win.bind("<ButtonRelease-1>", self._on_click_or_drag_end)
        self.win.bind("<Button-3>", self._show_menu)
        self.win.bind("<Double-Button-1>", self._manual_chat)

        # --- Right-click menu ---
        self.menu = tk.Menu(self.win, tearoff=False)
        self.menu.add_command(label="AI 对话", command=self._manual_chat)
        self.menu.add_command(label="摸摸头", command=self._pet_head)
        self.action_menu = tk.Menu(self.menu, tearoff=False)
        self.action_menu.add_command(label="轻呼吸", command=lambda: self._play_command_action("breathe"))
        self.action_menu.add_command(label="眨眨眼", command=lambda: self._play_command_action("blink"))
        self.action_menu.add_command(label="开心一下", command=lambda: self._play_command_action("happy"))
        self.action_menu.add_command(label="挥挥手", command=lambda: self._play_command_action("wave"))
        self.action_menu.add_command(label="睡一会", command=lambda: self._play_command_action("sleep"))
        self.action_menu.add_command(label="点点头", command=lambda: self._play_command_action("nod"))
        self.action_menu.add_command(label="摇摇头", command=lambda: self._play_command_action("shake"))
        self.action_menu.add_command(label="歪头看看", command=lambda: self._play_command_action("curious"))
        self.action_menu.add_command(label="认真听", command=lambda: self._play_command_action("listen"))
        self.action_menu.add_command(label="害羞一下", command=lambda: self._play_command_action("shy"))
        self.action_menu.add_command(label="伸懒腰", command=lambda: self._play_command_action("stretch"))
        self.action_menu.add_command(label="走两步", command=lambda: self._play_command_action("walk"))
        self.action_menu.add_separator()
        self.action_menu.add_command(label="随机互动", command=lambda: self._play_command_sequence("random"))
        self.action_menu.add_separator()
        self.action_menu.add_command(label="动作巡检", command=lambda: self._play_command_sequence("patrol"))
        self.action_menu.add_command(label="打招呼", command=lambda: self._play_command_sequence("greet"))
        self.action_menu.add_command(label="收到啦", command=lambda: self._play_command_sequence("ack"))
        self.action_menu.add_command(label="慢慢呼吸", command=lambda: self._play_command_sequence("calm_breathe"))
        self.action_menu.add_command(label="晚安", command=lambda: self._play_command_sequence("goodnight"))
        self.menu.add_cascade(label="动作", menu=self.action_menu)
        self.menu.add_command(label="安静模式", command=self._quiet_mode)
        self.menu.add_command(label="陪伴模式", command=self._normal_mode)
        self.menu.add_command(label="活泼一下", command=self._lively_mode)
        self.menu.add_command(label="站好", command=self._stand_still)
        self.menu.add_command(label="回到屏幕内", command=self._bring_back_to_screen)
        self.menu.add_command(label="控制面板", command=self._open_control_panel)
        self.menu.add_command(label="退出", command=self._quit)

        # --- Position (restore saved or default) ---
        pos_name = "pet_position_mac.txt" if self._is_macos else "pet_position.txt"
        self._pos_file = Path(_state_path(pos_name))
        saved = self._load_position()
        width, height = self.win.winfo_screenwidth(), self.win.winfo_screenheight()
        base_w = max(60, int(size * 0.6))
        base_h = size + 6
        if start_position and len(start_position) == 2:
            self.x, self.y = start_position
        elif saved:
            self.x, self.y = saved
        else:
            self.x = max(0, width - size - 60)   # bottom-right corner
            self.y = max(0, height - size - 120)
        self.x, self.y = self._clamp_to_screen(
            self.x,
            self.y,
            width,
            height,
            base_w,
            base_h,
        )
        self.win.geometry(f"+{int(self.x)}+{int(self.y)}")
        _debug_log(
            self._debug,
            f"窗口初始化: screen={width}x{height}, size={size}, base={base_w}x{base_h}, pos=({int(self.x)},{int(self.y)})",
        )
        self.win.deiconify()
        self.win.lift()
        try:
            self.win.focus_force()
        except tk.TclError:
            pass

        self.win.update_idletasks()
        self.screen_w = self.win.winfo_screenwidth()
        self.screen_h = self.win.winfo_screenheight()
        self.pet_w = base_w
        self.pet_h = base_h

        # AI chat client
        self._ai = AIChatClient(chat_history=chat_history, memory=self._memory)
        if self._ai.enabled:
            _print_with_utf8(f"[启动信息] DeepSeek AI：已开启 ({self._ai.model})")
        else:
            _print_with_utf8("[启动信息] DeepSeek AI：未配置，使用本地简易回复")
        if self._memory:
            _print_with_utf8(f"[启动信息] 本地记忆：已开启 ({self._memory.path})")
        else:
            _print_with_utf8("[启动信息] 本地记忆：未开启")
        _print_with_utf8(f"[启动信息] 主动说话：{'已开启' if self._auto_chat_enabled else '已关闭'}")
        _print_with_utf8(
            f"[启动信息] 外观动作：透明度={int(round(self._window_alpha * 100))}% "
            f"置顶={'开' if self._topmost else '关'} 模式={self._activity_mode} "
            f"细腻度={self._motion_detail} 自动大动作={'开' if self._auto_big_actions else '关'}"
        )

        # Say hi on startup
        self.win.after(800, self._startup_hello)

        self._tick()

    # Chat bubble

    def _apply_window_transparency(self, window):
        if self._force_visible:
            self._window_transparent = False
            try:
                window.configure(bg=self._window_bg)
            except tk.TclError:
                pass
            return

        if self._is_macos:
            try:
                window.wm_attributes("-transparent", True)
                window.configure(bg="systemTransparent")
                return
            except tk.TclError:
                pass

        try:
            window.wm_attributes("-transparentcolor", self._chroma)
        except tk.TclError:
            self._window_transparent = False
            try:
                window.configure(bg="systemTransparent" if self._is_macos else "#000000")
            except tk.TclError:
                window.configure(bg="#000000")

    def _hold_action(self, action: str, seconds: float = 2.0):
        if action != self.action:
            self._action_started_elapsed = self.elapsed
        self.action = action
        self.action_end = time.time() + max(0.5, seconds)

    def _settle_after_interaction(self, seconds: float = 7.0, reset_idle_chat: bool = True):
        now = time.time()
        self._interaction_calm_until = max(self._interaction_calm_until, now + max(1.0, seconds))
        if reset_idle_chat:
            self.last_chat = now
            self.next_chat = random.randint(self._chat_interval_min + 90, self._chat_interval_max + 180)

    def _startup_hello(self):
        if _startup_greeting_due():
            _mark_startup_greeting_seen()
            self._show_chat("我在。", duration_ms=1800)
        self._settle_after_interaction(8.0)
        self._hold_action("look", 3.2)

    def _detect_reaction(self, text: str) -> dict:
        """Pick a lightweight reaction for expression and reply tone."""
        text_lower = text.lower()
        normalized = text_lower.strip().strip("。.!！?？~～")
        for rule in self._reaction_rules:
            exact = tuple(str(item).lower() for item in rule.get("exact", ()))
            if normalized in exact:
                return rule
            if any(keyword in text_lower for keyword in rule["keywords"]):
                return rule
        return {
            "name": "neutral",
            "action": "talk",
            "hint": "普通日常聊天。回复自然、短一点，像真实对话。",
            "local_replies": [],
        }

    def _smart_reply(self, text: str, reaction: dict | None = None) -> str:
        """Generate a contextual reply based on time + keywords."""
        import datetime
        hour = datetime.datetime.now().hour

        if reaction and reaction.get("name") != "neutral" and reaction.get("local_replies"):
            return random.choice(reaction["local_replies"])

        # Prefer memory-style local replies from chat logs first
        if self._chat_history:
            recall = self._chat_history.suggest_reply(text)
            if recall:
                return recall

        # Check keywords first
        text_lower = text.lower()
        for keywords, replies in self._keyword_replies.items():
            if any(kw in text_lower for kw in keywords):
                return random.choice(replies)

        # Time-based
        for hr_range, replies in self._time_greetings.items():
            if hour in hr_range:
                return random.choice(replies)

        # Fallback
        fallbacks = [
            f'"{text}"你说得对吧？',
            "你说得对。",
            "确实挺会说。",
            "你这句我听见了。",
            "有新消息吗？",
        ]
        return random.choice(fallbacks)

    def _quick_reply(self, text: str, reaction: dict | None = None) -> str:
        """Immediate local response shown while the online reply is still loading."""
        name = (reaction or {}).get("name", "neutral")
        replies_by_reaction = {
            "comfort": [
                "我在呢，先别急。",
                "靠过来一点，我听着。",
                "先缓一下，我陪你。",
            ],
            "happy": [
                "哈哈，我先跟着开心一下。",
                "这个我喜欢，等我想想怎么回。",
                "你这样讲我都笑了。",
            ],
            "praise": [
                "嗯哼，我有点不好意思。",
                "这句我收下啦。",
                "那我偷偷开心一下。",
            ],
            "affection": [
                "抱一下，我在。",
                "我也想你，等我一下。",
                "嗯，我听到了。",
            ],
            "help": [
                "我看一下，我们慢慢来。",
                "先别急，我帮你想。",
                "这个可以拆开看。",
            ],
            "correct": [
                "嗯，我收回来。",
                "好，不这样。",
                "我重新听你说。",
            ],
            "question": [
                "嗯？我想一下。",
                "我听到了，等我一下。",
                "这个我认真看一下。",
            ],
            "thanks": [
                "不用谢，等我一下。",
                "嗯哼，我收到啦。",
                "小事，我在。",
            ],
            "night": [
                "嗯，别撑太久。",
                "我陪你收个尾。",
                "好，慢慢来。",
            ],
            "ack": [
                "收到啦。",
                "嗯嗯。",
                "好，我知道了。",
            ],
            "neutral": [
                "我想一下噢。",
                "等我一下，我在看。",
                "嗯，我听见了。",
            ],
        }
        return random.choice(replies_by_reaction.get(name, replies_by_reaction["neutral"]))

    def _reaction_reply_action(self, reaction: dict | None) -> str:
        name = (reaction or {}).get("name", "neutral")
        return _reply_action_for_reaction_name(str(name))

    def _ai_wait_action(self, reaction: dict | None, elapsed: float) -> str:
        name = (reaction or {}).get("name", "neutral")
        return _wait_action_for_reaction_name(str(name), elapsed)

    def _update_ai_wait_action(self, now: float) -> None:
        result = self._ai_result if isinstance(self._ai_result, dict) else {}
        try:
            started_at = float(result.get("started_at", now))
        except Exception:
            started_at = now
        reaction = result.get("reaction") or self._pending_reaction
        reaction_name = str((reaction or {}).get("name", "neutral"))
        self._active_sequence_label = _wait_motion_label(reaction_name)
        self._current_sequence_caption = ""
        wait_action = self._ai_wait_action(reaction, max(0.0, now - started_at))
        if self.action != wait_action:
            self._edge_peek = None
            self._hold_action(wait_action, 1.1)
        else:
            self.action_end = now + 0.6

    def _play_reply_motion(self, reaction: dict | None, reply: str) -> None:
        name = str((reaction or {}).get("name", "neutral"))
        steps = _reply_motion_steps_for_reaction(name, len(reply or ""))
        self._edge_peek = None
        self._action_queue.clear()
        if not steps:
            return
        self._active_sequence_label = _reply_motion_label(name)
        self._current_sequence_caption = ""
        self._action_queue.extend(steps[1:])
        self._hold_action(steps[0][0], steps[0][1])

    def _reposition_chat(self):
    # Chat bubble
        self.chat_win.update_idletasks()
        cw = self.chat_win.winfo_width()
        ch = self.chat_win.winfo_height()
        bx = self.x + self.pet_w // 2 - cw // 2
        by = self.y - ch - 6
        bx = max(0, min(bx, max(0, self.screen_w - cw)))
        by = max(0, min(by, max(0, self.screen_h - ch)))
        self.chat_win.geometry(f"+{int(round(bx))}+{int(round(by))}")

    def _show_chat(self, text: str, duration_ms: int = 4500):
    # Chat bubble
        duration_ms = _chat_duration_ms(text, duration_ms)
        self._settle_after_interaction(max(3.0, duration_ms / 1000.0 + 1.5))
        # Cancel any pending hide timer
        if self._chat_scheduled_hide:
            self.chat_win.after_cancel(self._chat_scheduled_hide)
            self._chat_scheduled_hide = None

        # Don't force wink - keep whatever action is running
        self._hide_chat_entry()
        self.chat_label.configure(text=text)
        self.chat_win.deiconify()
        self._reposition_chat()
        self._chat_visible = True

        # Auto-hide after delay
        self._chat_scheduled_hide = self.chat_win.after(duration_ms, self._hide_chat)

    def _show_chat_entry(self):
        """Show text entry for user to type a message."""
        if self._chat_scheduled_hide:
            self.chat_win.after_cancel(self._chat_scheduled_hide)
            self._chat_scheduled_hide = None

        self.chat_label.configure(text="你现在想说什么？")
        self._entry_frame.pack(side="bottom", fill="x", padx=2, pady=(0, 2))
        self._entry_var.set("")
        self._entry.focus_set()
        self.chat_win.deiconify()
        self._reposition_chat()
        self._chat_visible = True
        self._settle_after_interaction(8.0)
        self._hold_action("look", 3.0)

    def _hide_chat_entry(self):
        self._entry_frame.pack_forget()
        self._entry_var.set("")

    def _after_chat_hidden(self):
        if self.dragging or (self._ai_thread is not None and self._ai_thread.is_alive()):
            return
        self._settle_after_interaction(8.0)
        if self.action not in {"typing", "drag"}:
            self._hold_action("rest", 3.2)

    def _hide_chat(self):
        self._hide_chat_entry()
        self.chat_win.withdraw()
        self._chat_visible = False
        self._chat_scheduled_hide = None
        self._after_chat_hidden()

    def _on_entry_submit(self, event):
        text = self._entry_var.get().strip()
        self._hide_chat_entry()
        if not text:
            self._hide_chat()
            return

        self._submit_user_text(text)

    def _submit_user_text(self, text: str):
        if not text.strip():
            return
        self._action_queue.clear()
        if self._memory:
            self._memory.learn_from_user(text)
            self._memory.save()
        # Show what user said briefly, then switch to thinking
        reaction = self._detect_reaction(text)
        self._pending_reaction = reaction
        self._settle_after_interaction(12.0)
        self._show_chat(f"\u300c{text}\u300d", duration_ms=1800)
        self._hold_action("look", 2.6)

        # Kick off AI call in background thread (does NOT touch tkinter)
        quick_reply = self._quick_reply(text, reaction)
        result_holder = {
            "reply": None,
            "user": text,
            "reaction": reaction,
            "quick_reply": quick_reply,
            "quick_shown": False,
            "started_at": time.time(),
        }

        def _show_quick_reply():
            if self._ai_result is result_holder and self._ai_thread is not None and self._ai_thread.is_alive():
                result_holder["quick_shown"] = True
                self._show_chat(quick_reply, duration_ms=6500)
                self._update_ai_wait_action(time.time())

        self.win.after(650, _show_quick_reply)

        def _call_ai():
            try:
                reply = None
                if self._ai.enabled:
                    reply = self._ai.chat(text, reaction_hint=reaction.get("hint"))
                if not reply:
                    reply = quick_reply
                result_holder["reply"] = reply
            except Exception as e:
                print(f"[AI Error] {e}")
                result_holder["reply"] = quick_reply

        self._ai_thread = threading.Thread(target=_call_ai, daemon=True)
        self._ai_result = result_holder
        self._ai_thread.start()

    def _manual_chat(self, event=None):
        """Right-click -> say something."""
        self._show_chat_entry()

    def _pet_head(self):
        now = time.time()
        if now - self._last_head_pat_at < 3.0:
            self._settle_after_interaction(6.0, reset_idle_chat=False)
            self._hold_action("look", 1.2)
            return
        self._last_head_pat_at = now
        self._edge_peek = None
        self._action_queue = _head_pat_steps()
        self._active_sequence_label = "摸摸头收到。"
        self._current_sequence_caption = ""
        self._settle_after_interaction(8.0)
        self._show_chat(random.choice(["嘿嘿。", "嗯嗯，在呢。", "摸摸头收到。", "好乖地待着。"]), duration_ms=3200)
        self._advance_action_queue()

    def _quiet_mode(self):
        self._action_queue.clear()
        self._lively_until = 0.0
        self._auto_chat_enabled = False
        self._activity_mode = "quiet"
        self._auto_big_actions = False
        (
            self._move_prob_x,
            self._move_prob_y,
            self._move_amount_x,
            self._move_amount_y,
        ) = _movement_profile("quiet", self._motion_detail)
        self.next_chat = 10 ** 9
        self._show_chat("好，我安静陪你。", duration_ms=3000)
        self._hold_action("look", 2.0)

    def _normal_mode(self):
        self._action_queue.clear()
        self._lively_until = 0.0
        self._auto_chat_enabled = True
        self._activity_mode = "normal"
        self._auto_big_actions = False
        self._chat_interval_min, self._chat_interval_max = _auto_chat_bounds("normal", 420, 900)
        (
            self._move_prob_x,
            self._move_prob_y,
            self._move_amount_x,
            self._move_amount_y,
        ) = _movement_profile("normal", self._motion_detail)
        self.last_chat = time.time()
        self.next_chat = random.randint(self._chat_interval_min, self._chat_interval_max)
        self._show_chat("好，我陪着你。", duration_ms=2600)
        self._hold_action("look", 2.8)

    def _lively_mode(self, duration_seconds: float = _TEMP_LIVELY_SECONDS):
        self._action_queue.clear()
        self._auto_chat_enabled = True
        self._activity_mode = "lively"
        self._auto_big_actions = True
        duration_seconds = max(2.0, min(float(duration_seconds), 600.0))
        self._lively_until = time.time() + duration_seconds
        self._chat_interval_min, self._chat_interval_max = _auto_chat_bounds("lively", 180, 420)
        (
            self._move_prob_x,
            self._move_prob_y,
            self._move_amount_x,
            self._move_amount_y,
        ) = _movement_profile("lively", self._motion_detail)
        cooldown_scale = _motion_cooldown_scale(self._motion_detail)
        self._next_mouse_watch_allowed = time.time() + 2.0 * cooldown_scale
        self._next_edge_peek_allowed = time.time() + 35.0 * cooldown_scale
        self._next_stretch_allowed = time.time() + 45.0 * cooldown_scale
        self.last_chat = time.time()
        self.next_chat = random.randint(self._chat_interval_min, self._chat_interval_max)
        self._show_chat("好，我活泼一会儿。", duration_ms=2600)
        self._hold_action("wave", 2.8)

    def _restore_companion_after_lively(self):
        self._lively_until = 0.0
        self._activity_mode = "normal"
        self._auto_big_actions = False
        self._chat_interval_min, self._chat_interval_max = _auto_chat_bounds("normal", 420, 900)
        (
            self._move_prob_x,
            self._move_prob_y,
            self._move_amount_x,
            self._move_amount_y,
        ) = _movement_profile("normal", self._motion_detail)
        self._edge_peek = None
        self.last_chat = time.time()
        self.next_chat = random.randint(self._chat_interval_min, self._chat_interval_max)
        _print_with_utf8("[状态信息] 临时活泼结束，已恢复陪伴模式")
        self._show_chat("我安静回来啦。", duration_ms=2200)
        self._hold_action("rest", 2.6)

    def _check_lively_timeout(self, now: float):
        if self._activity_mode == "lively" and self._lively_until and now >= self._lively_until:
            self._restore_companion_after_lively()

    def _stand_still(self):
        self._action_queue.clear()
        self._lively_until = 0.0
        self._activity_mode = "normal"
        self._auto_big_actions = False
        self._chat_interval_min, self._chat_interval_max = _auto_chat_bounds("normal", 420, 900)
        (
            self._move_prob_x,
            self._move_prob_y,
            self._move_amount_x,
            self._move_amount_y,
        ) = _movement_profile("normal", self._motion_detail)
        self._edge_peek = None
        self.last_chat = time.time()
        self.next_chat = random.randint(self._chat_interval_min, self._chat_interval_max)
        self._show_chat("好，我站好。", duration_ms=1800)
        self._hold_action("rest", 3.5)

    def _bring_back_to_screen(self):
        self._action_queue.clear()
        try:
            self.screen_w = self.win.winfo_screenwidth()
            self.screen_h = self.win.winfo_screenheight()
        except tk.TclError:
            pass
        target_x = max(0, self.screen_w - self.pet_w - 60)
        target_y = max(0, self.screen_h - self.pet_h - 120)
        self.x, self.y = self._clamp_to_screen(
            target_x,
            target_y,
            self.screen_w,
            self.screen_h,
            self.pet_w,
            self.pet_h,
        )
        self._edge_peek = None
        self.win.geometry(f"+{int(self.x)}+{int(self.y)}")
        self._save_position()
        if self._chat_visible:
            self._reposition_chat()
        self._settle_after_interaction(8.0)
        self._show_chat("我回来啦。", duration_ms=2200)
        self._hold_action("look", 2.4)

    def _play_command_action(self, pet_action: str):
        self._action_queue.clear()
        pet_action = pet_action.strip()
        durations = {
            "mouse_watch": 3.2,
            "edge_peek": 7.0,
            "sleep": 6.0,
            "stretch": 4.2,
            "glance": 3.2,
            "breathe": 5.0,
            "nod": 2.2,
            "shake": 2.2,
            "curious": 3.2,
            "shy": 3.0,
            "listen": 3.0,
            "look": 3.0,
            "talk": 2.6,
            "typing": 2.8,
            "happy": 2.5,
            "wink": 2.0,
            "blink": 0.5,
            "wave": 3.0,
            "walk": 4.5,
        }
        if pet_action not in durations:
            return
        manual_spec = _manual_action_sequence_for_action(pet_action)
        if manual_spec:
            self._edge_peek = None
            self._action_queue = _sequence_steps(manual_spec)
            self._active_sequence_label = str(manual_spec.get("label", ""))
            self._current_sequence_caption = ""
            self._settle_after_interaction(8.0)
            if self._active_sequence_label:
                self._show_chat(self._active_sequence_label, duration_ms=1800)
            self._advance_action_queue()
            return
        if pet_action == "edge_peek":
            if self.action != "edge_peek":
                self._action_started_elapsed = self.elapsed
            self.action = "edge_peek"
            self.action_end = time.time() + durations[pet_action]
            self._start_edge_peek()
        else:
            self._edge_peek = None
            self._hold_action(pet_action, durations[pet_action])
        label = {
            "mouse_watch": "我在看。",
            "edge_peek": "探头看看。",
            "sleep": "困一下。",
            "stretch": "伸个懒腰。",
            "glance": "看你一眼。",
            "breathe": "我在这里。",
            "nod": "嗯嗯。",
            "shake": "不是这样。",
            "curious": "嗯？",
            "shy": "有点不好意思。",
            "listen": "我在听。",
            "blink": "眨一下。",
            "talk": "我在听。",
            "typing": "我想一下。",
        }.get(pet_action)
        if label:
            self._show_chat(label, duration_ms=1800)

    def _advance_action_queue(self) -> bool:
        if not self._action_queue:
            self._active_sequence_label = ""
            self._current_sequence_caption = ""
            return False
        step = self._action_queue.pop(0)
        pet_action = str(step[0])
        seconds = float(step[1])
        caption = str(step[2]).strip() if len(step) >= 3 else ""
        self._current_sequence_caption = caption
        if caption:
            self._show_chat(f"巡检：{caption}", duration_ms=max(900, min(1600, int(seconds * 700))))
        if pet_action == "edge_peek":
            if self.action != "edge_peek":
                self._action_started_elapsed = self.elapsed
            self.action = "edge_peek"
            self.action_end = time.time() + max(0.5, seconds)
            self._start_edge_peek()
        else:
            self._edge_peek = None
            self._hold_action(pet_action, seconds)
        return True

    def _play_command_sequence(self, sequence_name: str):
        spec = _resolve_command_sequence(sequence_name)
        if not spec:
            return
        steps = _sequence_steps(spec)
        total_seconds = sum(float(step[1]) for step in steps)
        self._edge_peek = None
        self._action_queue = steps
        self._active_sequence_label = str(spec["label"])
        self._current_sequence_caption = ""
        self._settle_after_interaction(total_seconds + 5.0)
        self._show_chat(str(spec["label"]), duration_ms=2200)
        self._advance_action_queue()

    def _poll_external_command(self):
        if self._ai_thread is not None and self._ai_thread.is_alive():
            return
        command = _read_pet_command(self._command_file)
        if not command:
            return
        action = str(command.get("action", "")).strip()
        if action == "chat":
            text = str(command.get("text", "")).strip()
            if text:
                self._submit_user_text(text)
        elif action == "open_chat":
            self._manual_chat()
        elif action == "play_action":
            self._play_command_action(str(command.get("pet_action", command.get("text", ""))))
        elif action == "play_sequence":
            self._play_command_sequence(str(command.get("sequence", command.get("text", ""))))
        elif action == "play_reply_motion":
            reaction_name = str(command.get("reaction", command.get("text", "neutral"))).strip().lower() or "neutral"
            reply = str(command.get("reply", "我在。"))
            self._play_reply_motion({"name": reaction_name}, reply)
        elif action == "play_wait_motion":
            reaction_name = str(command.get("reaction", command.get("text", "neutral"))).strip().lower() or "neutral"
            self._edge_peek = None
            self._action_queue = list(_wait_motion_steps_for_reaction(reaction_name))
            self._active_sequence_label = _wait_motion_label(reaction_name)
            self._current_sequence_caption = ""
            self._settle_after_interaction(8.0)
            self._advance_action_queue()
        elif action == "reset_position":
            self._bring_back_to_screen()
        elif action == "quit":
            self._quit()
            return
        elif action == "clear_memory":
            self._clear_memory()
        elif action == "reload_memory":
            if self._memory:
                self._memory.reload()
                self._show_chat("记忆已更新。", duration_ms=1800)
        elif action == "set_runtime_mode":
            mode = str(command.get("mode", command.get("text", ""))).strip().lower()
            if mode == "quiet":
                self._quiet_mode()
            elif mode in {"normal", "companion"}:
                self._normal_mode()
            elif mode in {"lively", "active"}:
                try:
                    duration = float(command.get("duration", command.get("seconds", _TEMP_LIVELY_SECONDS)))
                except Exception:
                    duration = _TEMP_LIVELY_SECONDS
                self._lively_mode(duration)
            elif mode in {"stand", "still", "stable"}:
                self._stand_still()

    def _say_something(self):
        """Auto idle chat."""
        line = random.choice(self.idle_lines)
        self._show_chat(line, duration_ms=random.randint(3000, 6000))
        self._hold_action("rest", 2.2)

    def _idle_chat_tick(self):
        if not self._auto_chat_enabled:
            return
        now = time.time()
        if now < self._interaction_calm_until:
            return
        if not self._chat_visible and now - self.last_chat >= self.next_chat:
            self.last_chat = now
            self.next_chat = random.randint(self._chat_interval_min, self._chat_interval_max)
            self._say_something()

    # Position persistence

    def _load_position(self) -> Tuple[int, int] | None:
        if self._pos_file.exists():
            try:
                text = self._pos_file.read_text(encoding="utf-8").strip()
                parts = text.split(",")
                if len(parts) == 2:
                    return int(round(float(parts[0].strip()))), int(round(float(parts[1].strip())))
            except Exception:
                pass
        return None

    def _clamp_to_screen(
        self, x: int, y: int, width: int, height: int, width_hint: int, height_hint: int
    ) -> Tuple[int, int]:
        """Keep pet inside screen so it never starts off-screen."""
        max_x = max(0, width - width_hint)
        max_y = max(0, height - height_hint)
        return (max(0, min(x, max_x)), max(0, min(y, max_y)))

    def _save_position(self):
        try:
            self._pos_file.write_text(f"{int(self.x)},{int(self.y)}", encoding="utf-8")
        except Exception:
            pass

    def _write_runtime_status(self, now: float):
        if now - self._last_status_write < 1.0:
            return
        self._last_status_write = now
        try:
            payload = {
                "running": True,
                "pid": os.getpid(),
                "updated_at": now,
                "renderer": getattr(self.renderer, "name", "unknown"),
                "window_engine": self._window_engine,
                "action": self.action,
                "mode": self._activity_mode,
                "motion_detail": self._motion_detail,
                "auto_big_actions": bool(self._auto_big_actions),
                "idle_breathe": bool(self._idle_breathe_enabled),
                "queue_count": len(self._action_queue),
                "sequence_label": self._active_sequence_label,
                "sequence_caption": self._current_sequence_caption,
                "chat_visible": bool(self._chat_visible),
                "ai_busy": bool(self._ai_thread is not None and self._ai_thread.is_alive()),
                "x": int(round(self.x)),
                "y": int(round(self.y)),
                "size": int(self.size),
            }
            tmp = self._status_file.with_suffix(".tmp")
            tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
            tmp.replace(self._status_file)
        except Exception:
            pass

    # Actions (calm)

    def _set_next_action(self):
        now = time.time()
        if now < self._interaction_calm_until:
            next_action = "look" if self._chat_visible else "rest"
            if next_action != self.action:
                self._action_started_elapsed = self.elapsed
            self.action = next_action
            self._edge_peek = None
            self.action_end = now + random.uniform(8.0, 14.0)
            return
        next_action = "rest"
        base = random.uniform(22.0, 38.0)
        for _ in range(10):
            candidate, candidate_base = _idle_action_for_mode(self._activity_mode, self._motion_detail)
            if candidate in _MAJOR_IDLE_ACTIONS and not self._auto_big_actions:
                continue
            if candidate == self._last_idle_action and candidate not in {"rest", "look"}:
                continue
            if candidate == "mouse_watch" and now < self._next_mouse_watch_allowed:
                continue
            if candidate == "edge_peek" and now < self._next_edge_peek_allowed:
                continue
            if candidate == "stretch" and now < self._next_stretch_allowed:
                continue
            next_action, base = candidate, candidate_base
            break
        if next_action != self.action:
            self._action_started_elapsed = self.elapsed
        self.action = next_action
        self._last_idle_action = self.action
        cooldown_scale = _motion_cooldown_scale(self._motion_detail)
        if self.action == "edge_peek":
            self._start_edge_peek()
            self._next_edge_peek_allowed = now + random.uniform(120.0, 190.0) * cooldown_scale
        else:
            self._edge_peek = None
        if self.action == "mouse_watch":
            self._next_mouse_watch_allowed = now + random.uniform(24.0, 42.0) * cooldown_scale
        if self.action == "stretch":
            self._next_stretch_allowed = now + random.uniform(150.0, 240.0) * cooldown_scale
        self.action_end = time.time() + max(1.5, base * _motion_duration_scale(self._motion_detail))

    def _start_edge_peek(self):
        left_dist = max(0.0, float(self.x))
        right_dist = max(0.0, float(self.screen_w - (self.x + self.pet_w)))
        edge_threshold = max(120.0, self.pet_w * 1.5)
        if min(left_dist, right_dist) <= edge_threshold:
            side = "left" if left_dist <= right_dist else "right"
            target_x = -int(self.pet_w * 0.14) if side == "left" else int(self.screen_w - self.pet_w * 0.86)
        else:
            direction = -1 if self.x < self.screen_w / 2 else 1
            target_x = int(self.x + direction * min(10.0, self.pet_w * 0.08))
        self._edge_peek = {
            "home": (float(self.x), float(self.y)),
            "target": (float(target_x), float(max(0, min(self.y, self.screen_h - self.pet_h)))),
            "return_at": time.time() + random.uniform(4.8, 6.4),
        }

    def _move_toward(self, target_x: float, target_y: float, rate: float = 0.08):
        self.x += (target_x - self.x) * rate
        self.y += (target_y - self.y) * rate

    def _track_mouse(self):
        now = time.time()
        if now - self._last_pointer_track < 0.028:
            return
        self._last_pointer_track = now
        try:
            px, py = self.win.winfo_pointerxy()
        except tk.TclError:
            self.renderer.set_pointer_direction(0.0, 0.0)
            return
        cx = self.x + self.pet_w / 2
        cy = self.y + self.pet_h * 0.30
        dx = px - cx
        dy = py - cy
        distance = math.hypot(dx, dy)
        gaze_limit = max(420.0, self.pet_h * 2.4)
        if distance > gaze_limit:
            self.renderer.set_pointer_direction(0.0, 0.0)
            return
        self.renderer.set_pointer_direction(
            max(-1.0, min(1.0, dx / max(160.0, self.pet_w * 1.35))),
            max(-1.0, min(1.0, dy / max(160.0, self.pet_h * 0.95))),
        )
        if self._activity_mode != "lively":
            return
        if self._motion_detail == "stable" and not self._auto_big_actions:
            return
        if now < self._interaction_calm_until:
            return
        if now < self._next_mouse_watch_allowed:
            return
        if now - self._last_mouse_track < 0.35:
            return
        self._last_mouse_track = now
        distance_limit = 220 if self._activity_mode == "lively" else 170
        if distance > distance_limit:
            return
        if self.action in {"rest", "look", "glance"}:
            if self.action != "mouse_watch":
                self._action_started_elapsed = self.elapsed
            self.action = "mouse_watch"
            self.action_end = max(self.action_end, now + (1.6 if self._activity_mode == "lively" else 1.2))
            cooldown_scale = _motion_cooldown_scale(self._motion_detail)
            cooldown_min, cooldown_max = (16.0, 28.0) if self._activity_mode == "lively" else (30.0, 46.0)
            self._next_mouse_watch_allowed = now + random.uniform(cooldown_min, cooldown_max) * cooldown_scale

    def _update_blink(self, now: float):
        if now < self._blink_until or now < self._next_blink_at:
            return
        if self.dragging or self._chat_visible:
            self._next_blink_at = now + random.uniform(12.0, 22.0)
            return
        if self.action in {"rest", "look", "glance", "mouse_watch"}:
            self._blink_started_elapsed = self.elapsed
            self._blink_until = now + random.uniform(0.22, 0.30)
            if random.random() < 0.06:
                self._next_blink_at = now + random.uniform(0.45, 0.85)
            else:
                self._next_blink_at = now + random.uniform(24.0, 46.0)
        else:
            self._next_blink_at = now + random.uniform(18.0, 32.0)

    # Mouse interaction

    def _on_drag_start(self, event):
        self.dragging = False
        self._drag_press_root = (event.x_root, event.y_root)
        self._drag_window_origin = (float(self.x), float(self.y))
        self._drag_pointer_offset = (float(event.x_root) - float(self.x), float(event.y_root) - float(self.y))
        self._drag_action_started = False
        self._last_drag_chat_reposition = 0.0

    def _on_drag_motion(self, event):
        dx = abs(event.x_root - self._drag_press_root[0])
        dy = abs(event.y_root - self._drag_press_root[1])
        if dx > 3 or dy > 3:
            self.dragging = True
        if self.dragging:
            self.x = float(event.x_root) - self._drag_pointer_offset[0]
            self.y = float(event.y_root) - self._drag_pointer_offset[1]
            self.x, self.y = self._clamp_to_screen(self.x, self.y, self.screen_w, self.screen_h, self.pet_w, self.pet_h)
            if not self._drag_action_started:
                self._edge_peek = None
                self._action_queue.clear()
                self._hold_action("drag", 0.8)
                self._drag_action_started = True
            self.win.geometry(f"+{int(round(self.x))}+{int(round(self.y))}")
            now = time.time()
            if self._chat_visible and now - self._last_drag_chat_reposition > 0.035:
                self._reposition_chat()
                self._last_drag_chat_reposition = now

    def _on_click_or_drag_end(self, event):
        if not self.dragging:
            now = time.time()
            self._settle_after_interaction(8.0)
            if now - self._last_click_response_at < 2.0:
                self._hold_action("look", 1.6)
                self.dragging = False
                return
            self._last_click_response_at = now
            self._show_chat(random.choice(self.click_responses), duration_ms=3500)
            self._edge_peek = None
            self._action_queue = [("happy", 1.4), ("rest", 1.8)]
            self._active_sequence_label = ""
            self._current_sequence_caption = ""
            self._advance_action_queue()
        else:
            self._save_position()
        self.dragging = False

    def _show_menu(self, event):
        try:
            self.menu.tk_popup(event.x_root, event.y_root)
        finally:
            self.menu.grab_release()

    def _open_control_panel(self):
        try:
            _open_control_panel()
            self._show_chat("控制面板打开啦。", duration_ms=1800)
        except Exception as exc:
            self._show_chat(f"控制面板打不开：{exc}", duration_ms=4500)

    # 鈹€鈹€ Movement 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€

    def _update_movement(self):
        self._track_mouse()
        if self.dragging:
            return
        if self._edge_peek:
            target_x, target_y = self._edge_peek["target"]
            if time.time() >= self._edge_peek["return_at"]:
                target_x, target_y = self._edge_peek["home"]
                self._move_toward(target_x, target_y, rate=0.045)
                if abs(self.x - target_x) < 3 and abs(self.y - target_y) < 3:
                    self._edge_peek = None
                    if self.action != "look":
                        self._action_started_elapsed = self.elapsed
                    self.action = "look"
                    self.action_end = time.time() + random.uniform(1.0, 2.0)
            else:
                self._move_toward(target_x, target_y, rate=0.05)
            self.x = max(-int(self.pet_w * 0.40), min(self.x, self.screen_w - int(self.pet_w * 0.60)))
            self.y = max(0, min(self.y, self.screen_h - self.pet_h))
            self.win.geometry(f"+{int(round(self.x))}+{int(round(self.y))}")
            if self._chat_visible:
                self._reposition_chat()
            return
        # Very subtle idle drift
        if self._move_prob_x <= 0 and self._move_prob_y <= 0:
            return
        if random.random() < self._move_prob_x:
            self.x += random.uniform(-self._move_amount_x, self._move_amount_x)
        if random.random() < self._move_prob_y:
            self.y += random.uniform(-self._move_amount_y, self._move_amount_y)
        # Clamp
        self.x = max(0, min(self.x, self.screen_w - self.pet_w))
        self.y = max(0, min(self.y, self.screen_h - self.pet_h))
        self.win.geometry(f"+{int(round(self.x))}+{int(round(self.y))}")
        if self._chat_visible:
            self._reposition_chat()

    # Draw loop

    def _idle_breathe_active(self) -> bool:
        return (
            bool(self._idle_breathe_enabled)
            and self._activity_mode != "quiet"
            and self.action == "rest"
            and not self.dragging
            and not self._chat_visible
            and not self._action_queue
            and self._edge_peek is None
            and not (self._ai_thread is not None and self._ai_thread.is_alive())
        )

    def _render_with_transition(self, render_action: str, delta: float, action_elapsed_override: float | None = None) -> RendererFrame:
        now = time.time()
        if render_action != self._visual_action:
            self._visual_prev_frame = self._last_render_frame
            self._visual_prev_action = self._visual_action
            self._visual_action = render_action
            self._visual_transition_started = now
            self._visual_transition_duration = _visual_transition_seconds(self._visual_prev_action, render_action)
            self._visual_transition_masked = _use_masked_visual_transition(self._visual_prev_action, render_action)

        action_elapsed = (
            max(0.0, action_elapsed_override)
            if action_elapsed_override is not None
            else max(0.0, self.elapsed - self._action_started_elapsed)
        )
        frame = self.renderer.render(render_action, action_elapsed, delta)
        if not self._visual_prev_action or self._visual_transition_duration <= 0:
            return frame

        progress = (now - self._visual_transition_started) / max(0.01, self._visual_transition_duration)
        if progress >= 1.0:
            self._visual_prev_action = None
            self._visual_prev_frame = None
            return frame
        if "drag" in {render_action, self._visual_prev_action}:
            return frame

        prev_frame = self._visual_prev_frame or self.renderer.render(self._visual_prev_action, action_elapsed, delta)
        if prev_frame.image.size != frame.image.size:
            return frame
        alpha = max(0.0, min(1.0, progress))
        alpha = alpha * alpha * (3.0 - 2.0 * alpha)
        if _use_pose_switch_transition(self._visual_prev_action, render_action):
            blended = _pose_switch_transition_images(
                prev_frame.image,
                frame.image,
                alpha,
                self._visual_prev_action,
                render_action,
            )
        else:
            blended = _blend_transition_images(
                prev_frame.image,
                frame.image,
                alpha,
                bool(self._visual_transition_masked),
            )
        return RendererFrame(blended, offset_x=frame.offset_x, offset_y=frame.offset_y)

    def _draw_frame(self, delta: float):
        self.elapsed += delta
        render_action = "drag" if self.dragging else self.action
        if render_action == "rest" and self._idle_breathe_active():
            render_action = "breathe"
        action_elapsed_override = None
        if not self.dragging and time.time() < self._blink_until:
            render_action = "blink"
            action_elapsed_override = max(0.0, self.elapsed - self._blink_started_elapsed)
        frame = self._render_with_transition(render_action, delta, action_elapsed_override)
        self._last_render_frame = frame
        from PIL import ImageTk

        render_img = _remove_dark_alpha_fringe(frame.image)
        if self._window_transparent and not self._is_macos:
            render_img = _tk_chroma_key_image(render_img, self._chroma)

        # Release old photo to prevent memory leak
        if self._last_photo is not None:
            self._last_photo = None
        self.photo = ImageTk.PhotoImage(render_img)
        self._last_photo = self.photo
        self.label.configure(image=self.photo)
        self.label.image = self.photo

    def _tick(self):
        now = time.time()
        delta = now - self.last_time
        self.last_time = now

        # Poll AI result from background thread (main thread only)
        if self._ai_thread is not None and self._ai_thread.is_alive():
            self._update_ai_wait_action(now)
        elif self._ai_result is not None:
            if self._ai_thread is None or not self._ai_thread.is_alive():
                reply = self._ai_result.get("reply")
                if reply:
                    user_text = self._ai_result.get("user", "")
                    reaction = self._ai_result.get("reaction") or self._pending_reaction
                    quick_reply = self._ai_result.get("quick_reply", "")
                    quick_shown = self._ai_result.get("quick_shown", False)
                    if self._memory:
                        self._memory.add_turn(user_text, reply)
                    # Show AI reply, reset idle timer so it doesn't interrupt
                    if not (quick_shown and reply.strip() == str(quick_reply).strip()):
                        self._show_chat(reply, duration_ms=6000)
                        self._play_reply_motion(reaction, reply)
                    else:
                        self._play_reply_motion(reaction, reply)
                    self.last_chat = now  # reset idle timer
                    self.next_chat = random.randint(self._chat_interval_min + 60, self._chat_interval_max + 60)  # longer delay after AI
                self._ai_result = None
                self._ai_thread = None
                self._pending_reaction = None

        self._check_lively_timeout(now)
        if now >= self.action_end:
            if not self._advance_action_queue():
                self._set_next_action()
        self._update_movement()
        self._update_blink(now)
        self._draw_frame(delta)
        self._write_runtime_status(now)
        self._poll_external_command()

        # Only idle chat if we're not showing AI reply
        if self._ai_thread is None:
            self._idle_chat_tick()

        self.win.after(self.refresh_ms, self._tick)

    def _clear_memory(self):
        if not self._memory:
            self._show_chat("现在没有开启记忆。", duration_ms=2500)
            return
        self._memory.clear()
        self._show_chat("记忆清空啦。", duration_ms=2500)

    def _quit(self):
        self.chat_win.destroy()
        self.win.destroy()
        self.root.destroy()

    def run(self):
        result = self.root.mainloop()
        _print_with_utf8("[退出信息] Tk 主循环已结束")
        return result


class _QtPetLabel:
    """Small event-forwarding QLabel wrapper for the Qt transparent window."""

    def __new__(cls, pet):
        from PySide6 import QtWidgets

        class PetLabel(QtWidgets.QLabel):
            def mousePressEvent(self, event):
                pet._qt_mouse_press(event)

            def mouseMoveEvent(self, event):
                pet._qt_mouse_move(event)

            def mouseReleaseEvent(self, event):
                pet._qt_mouse_release(event)

            def mouseDoubleClickEvent(self, event):
                pet._manual_chat()

            def contextMenuEvent(self, event):
                pet._show_menu(event.globalPos())

        return PetLabel()


class _QtChatBubble:
    """Painted chat bubble with a rounded body and a small tail."""

    def __new__(cls):
        from PySide6 import QtCore, QtGui, QtWidgets

        class ChatBubble(QtWidgets.QWidget):
            def __init__(self):
                super().__init__()
                self._bubble_width = 300
                self._tail_h = 10
                self._side_pad = 14
                self._top_pad = 9
                self._bottom_pad = 9
                self._label = QtWidgets.QLabel(self)
                self._label.setWordWrap(True)
                self._label.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter)
                self._label.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse)
                self._label.setStyleSheet(
                    "QLabel { color: #202124; background: transparent;"
                    " font-family: 'Microsoft YaHei UI'; font-size: 13px; line-height: 1.35; }"
                )
                layout = QtWidgets.QVBoxLayout(self)
                layout.setContentsMargins(
                    self._side_pad,
                    self._top_pad,
                    self._side_pad,
                    self._bottom_pad + self._tail_h,
                )
                layout.setSpacing(0)
                layout.addWidget(self._label)
                self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True)
                self.setFixedWidth(self._bubble_width)

            def setText(self, text: str):
                self._label.setText(text)
                self._label.adjustSize()
                self.adjustSize()
                self.update()

            def setFixedWidth(self, width: int):
                self._bubble_width = int(width)
                super().setFixedWidth(self._bubble_width)
                self._label.setFixedWidth(max(80, self._bubble_width - self._side_pad * 2))
                self.adjustSize()

            def sizeHint(self):
                label_hint = self._label.sizeHint()
                height = self._top_pad + label_hint.height() + self._bottom_pad + self._tail_h
                return QtCore.QSize(self._bubble_width, max(48, height))

            def minimumSizeHint(self):
                return self.sizeHint()

            def adjustSize(self):
                self.resize(self.sizeHint())

            def paintEvent(self, _event):
                painter = QtGui.QPainter(self)
                painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True)
                fill = QtGui.QColor(255, 255, 255, 248)
                border = QtGui.QColor(226, 229, 233, 235)
                body = QtCore.QRectF(0.8, 0.8, self.width() - 1.6, self.height() - self._tail_h - 1.6)
                radius = 10.0
                painter.setPen(QtGui.QPen(border, 1.0))
                painter.setBrush(fill)
                painter.drawRoundedRect(body, radius, radius)

                cx = self.width() / 2.0
                tail_half = 8.0
                tail_top = body.bottom() - 0.3
                tail_bottom = self.height() - 1.0
                tail = QtGui.QPolygonF(
                    [
                        QtCore.QPointF(cx - tail_half, tail_top),
                        QtCore.QPointF(cx, tail_bottom),
                        QtCore.QPointF(cx + tail_half, tail_top),
                    ]
                )
                painter.setPen(QtCore.Qt.PenStyle.NoPen)
                painter.setBrush(fill)
                painter.drawPolygon(tail)
                painter.setPen(QtGui.QPen(border, 1.0))
                painter.drawLine(QtCore.QPointF(cx - tail_half, tail_top), QtCore.QPointF(cx, tail_bottom))
                painter.drawLine(QtCore.QPointF(cx, tail_bottom), QtCore.QPointF(cx + tail_half, tail_top))

        return ChatBubble()


class _QtConversationWindow:
    """Persistent, copyable conversation window for continuous chat."""

    def __new__(cls, pet):
        from PySide6 import QtCore, QtWidgets

        class MessageInput(QtWidgets.QPlainTextEdit):
            def keyPressEvent(self, event):
                if (
                    event.key() in {QtCore.Qt.Key.Key_Return, QtCore.Qt.Key.Key_Enter}
                    and not (event.modifiers() & QtCore.Qt.KeyboardModifier.ShiftModifier)
                ):
                    conversation._send_current()
                    event.accept()
                    return
                super().keyPressEvent(event)

        class ConversationWindow(QtWidgets.QWidget):
            def __init__(self):
                super().__init__()
                self._pet = pet
                self._messages: list[dict] = []
                self._allow_close = False
                self._busy = False
                self.setWindowTitle(f"{APP_NAME} · AI 对话")
                flags = QtCore.Qt.WindowType.Window
                if pet._topmost:
                    flags |= QtCore.Qt.WindowType.WindowStaysOnTopHint
                self.setWindowFlags(flags)
                self.setMinimumSize(420, 460)
                self.resize(560, 620)
                self.setStyleSheet(
                    "QWidget { color: #242826; background: #f7f8f7;"
                    " font-family: 'Microsoft YaHei UI'; font-size: 13px; }"
                    "QScrollArea { border: 0; background: #f2f4f2; }"
                    "QPlainTextEdit { color: #242826; background: #ffffff;"
                    " border: 1px solid #cfd5d1; border-radius: 8px; padding: 8px; }"
                    "QPushButton { color: #242826; background: #ffffff;"
                    " border: 1px solid #cfd5d1; border-radius: 7px; padding: 7px 13px; }"
                    "QPushButton:hover { background: #eef2ef; }"
                    "QPushButton:disabled { color: #949a96; background: #ecefed; }"
                    "QPushButton#sendButton { color: #ffffff; background: #34785b;"
                    " border-color: #34785b; min-width: 72px; font-weight: 600; }"
                    "QPushButton#sendButton:hover { background: #2c684e; }"
                    "QPushButton#stopButton { color: #8b3b36; background: #fff8f7;"
                    " border-color: #dfc3c0; }"
                    "QPushButton#stopButton:hover { background: #f8e9e7; }"
                    "QToolButton { color: #65706a; background: transparent; border: 0;"
                    " padding: 2px 5px; font-size: 11px; }"
                    "QToolButton:hover { color: #2f6f55; background: #edf2ef; border-radius: 4px; }"
                )

                outer = QtWidgets.QVBoxLayout(self)
                outer.setContentsMargins(14, 12, 14, 14)
                outer.setSpacing(10)

                header = QtWidgets.QHBoxLayout()
                title = QtWidgets.QLabel("和她聊聊")
                title.setStyleSheet("font-size: 17px; font-weight: 700; color: #202421;")
                self._status = QtWidgets.QLabel("可以直接问任何问题")
                self._status.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignVCenter)
                self._status.setStyleSheet("color: #69716c; font-size: 12px;")
                header.addWidget(title)
                header.addStretch(1)
                header.addWidget(self._status)
                outer.addLayout(header)

                self._scroll = QtWidgets.QScrollArea()
                self._scroll.setWidgetResizable(True)
                self._scroll.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
                self._scroll.viewport().setStyleSheet("background: #f2f4f2;")
                self._message_host = QtWidgets.QWidget()
                self._message_host.setStyleSheet("background: #f2f4f2;")
                self._message_host.setSizePolicy(
                    QtWidgets.QSizePolicy.Policy.Expanding,
                    QtWidgets.QSizePolicy.Policy.Expanding,
                )
                self._message_layout = QtWidgets.QVBoxLayout(self._message_host)
                self._message_layout.setContentsMargins(12, 14, 12, 14)
                self._message_layout.setSpacing(10)
                self._message_layout.addStretch(1)
                self._scroll.setWidget(self._message_host)
                outer.addWidget(self._scroll, 1)

                tools = QtWidgets.QHBoxLayout()
                tools.setSpacing(8)
                copy_button = QtWidgets.QPushButton("复制全部")
                copy_button.clicked.connect(self.copy_all)
                new_button = QtWidgets.QPushButton("新对话")
                new_button.clicked.connect(self.new_session)
                self._stop = QtWidgets.QPushButton("停止生成")
                self._stop.setObjectName("stopButton")
                self._stop.setEnabled(False)
                self._stop.clicked.connect(self.stop_generation)
                tools.addWidget(copy_button)
                tools.addWidget(new_button)
                tools.addWidget(self._stop)
                tools.addStretch(1)
                outer.addLayout(tools)

                compose = QtWidgets.QHBoxLayout()
                compose.setSpacing(8)
                self._input = MessageInput()
                self._input.setPlaceholderText("输入消息，按 Enter 发送，Shift+Enter 换行")
                self._input.setFixedHeight(76)
                self._input.viewport().setStyleSheet("background: #ffffff;")
                self._send = QtWidgets.QPushButton("发送")
                self._send.setObjectName("sendButton")
                self._send.setFixedHeight(76)
                self._send.clicked.connect(self._send_current)
                compose.addWidget(self._input, 1)
                compose.addWidget(self._send)
                outer.addLayout(compose)

                self._load_recent_turns()

            def _load_recent_turns(self):
                turns = []
                if self._pet._memory:
                    turns = self._pet._memory.data.get("recent_turns", [])[-6:]
                for turn in turns:
                    user = str(turn.get("user", "")).strip() if isinstance(turn, dict) else ""
                    reply = str(turn.get("pet", "")).strip() if isinstance(turn, dict) else ""
                    if user:
                        self.append_message("user", user)
                    if reply:
                        self.append_message("pet", reply)
                if not self._messages:
                    self.append_message("pet", "我在，想聊什么都可以。", allow_regenerate=False)

            def append_message(
                self,
                role: str,
                text: str,
                message_id: str | None = None,
                allow_regenerate: bool = True,
            ) -> str | None:
                clean = str(text or "").strip()
                if not clean:
                    return None
                message_id = message_id or uuid.uuid4().hex[:12]
                row = QtWidgets.QWidget()
                row.setStyleSheet("background: transparent;")
                row.setSizePolicy(
                    QtWidgets.QSizePolicy.Policy.Expanding,
                    QtWidgets.QSizePolicy.Policy.Maximum,
                )
                row_layout = QtWidgets.QHBoxLayout(row)
                row_layout.setContentsMargins(0, 0, 0, 0)
                row_layout.setSpacing(0)

                bubble = QtWidgets.QFrame()
                bubble.setMaximumWidth(430)
                bubble.setSizePolicy(
                    QtWidgets.QSizePolicy.Policy.Preferred,
                    QtWidgets.QSizePolicy.Policy.Maximum,
                )
                bubble.setStyleSheet(
                    "QFrame { background: #dfeee6; border: 1px solid #c6dfd1; border-radius: 8px; }"
                    if role == "user"
                    else "QFrame { background: #ffffff; border: 1px solid #d9dedb; border-radius: 8px; }"
                )
                bubble_layout = QtWidgets.QVBoxLayout(bubble)
                bubble_layout.setContentsMargins(11, 8, 11, 9)
                bubble_layout.setSpacing(3)
                who = QtWidgets.QLabel("你" if role == "user" else APP_NAME)
                who.setStyleSheet("border: 0; background: transparent; color: #65706a; font-size: 11px;")
                message = QtWidgets.QLabel(clean)
                message.setWordWrap(True)
                message.setTextFormat(QtCore.Qt.TextFormat.PlainText)
                message.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse)
                message.setStyleSheet("border: 0; background: transparent; color: #202421; font-size: 13px;")
                bubble_layout.addWidget(who)
                bubble_layout.addWidget(message)
                actions = QtWidgets.QHBoxLayout()
                actions.setContentsMargins(0, 1, 0, 0)
                actions.setSpacing(2)

                def add_action(label: str, tooltip: str, callback) -> None:
                    button = QtWidgets.QToolButton()
                    button.setText(label)
                    button.setToolTip(tooltip)
                    button.clicked.connect(callback)
                    actions.addWidget(button)

                add_action("复制", "复制这条消息", lambda _checked=False, mid=message_id: self.copy_message(mid))
                if role == "pet" and allow_regenerate and self._previous_user_text_for_position(len(self._messages)):
                    add_action(
                        "重答",
                        "根据上一条问题重新回答",
                        lambda _checked=False, mid=message_id: self.regenerate_message(mid),
                    )
                add_action(
                    "删除",
                    "只从当前窗口删除",
                    lambda _checked=False, mid=message_id: self.delete_message(mid),
                )
                actions.addStretch(1)
                bubble_layout.addLayout(actions)
                if role == "user":
                    row_layout.addStretch(1)
                    row_layout.addWidget(bubble, 0, QtCore.Qt.AlignmentFlag.AlignTop)
                else:
                    row_layout.addWidget(bubble, 0, QtCore.Qt.AlignmentFlag.AlignTop)
                    row_layout.addStretch(1)
                self._messages.append(
                    {
                        "id": message_id,
                        "role": role,
                        "text": clean,
                        "row": row,
                        "label": message,
                    }
                )
                self._message_layout.insertWidget(self._message_layout.count() - 1, row)
                QtCore.QTimer.singleShot(0, self._scroll_to_bottom)
                return message_id

            def _message(self, message_id: str) -> dict | None:
                return next((item for item in self._messages if item.get("id") == message_id), None)

            def _previous_user_text_for_position(self, position: int) -> str:
                for item in reversed(self._messages[:position]):
                    if item.get("role") == "user":
                        return str(item.get("text", "")).strip()
                return ""

            def _previous_user_text(self, message_id: str) -> str:
                for index, item in enumerate(self._messages):
                    if item.get("id") == message_id:
                        return self._previous_user_text_for_position(index)
                return ""

            def copy_message(self, message_id: str) -> None:
                item = self._message(message_id)
                if not item:
                    return
                QtWidgets.QApplication.clipboard().setText(str(item.get("text", "")))
                self._status.setText("已复制这条消息")

            def delete_message(self, message_id: str) -> None:
                item = self._message(message_id)
                if not item:
                    return
                self._messages.remove(item)
                row = item.get("row")
                if row:
                    self._message_layout.removeWidget(row)
                    row.hide()
                    row.setParent(None)
                    row.deleteLater()
                self._status.setText("已从当前窗口删除")

            def update_message(self, message_id: str, text: str) -> bool:
                item = self._message(message_id)
                clean = str(text or "").strip()
                if not item or not clean:
                    return False
                item["text"] = clean
                label = item.get("label")
                if label:
                    label.setText(clean)
                QtCore.QTimer.singleShot(0, self._scroll_to_bottom)
                return True

            def regenerate_message(self, message_id: str) -> None:
                if self._busy:
                    self.show_busy_notice()
                    return
                user_text = self._previous_user_text(message_id)
                if not user_text:
                    self._status.setText("没有找到对应的问题")
                    return
                request = {"mode": "regenerate", "target_message_id": message_id}
                if self._pet._submit_user_text(
                    user_text,
                    from_conversation=True,
                    conversation_request=request,
                ):
                    self.set_busy(True)

            def _scroll_to_bottom(self):
                bar = self._scroll.verticalScrollBar()
                bar.setValue(bar.maximum())

            def _send_current(self):
                text = self._input.toPlainText().strip()
                if not text:
                    return
                if self._busy:
                    self._status.setText("上一条还在回复，稍等一下")
                    return
                message_id = uuid.uuid4().hex[:12]
                request = {"mode": "send", "user_message_id": message_id}
                if self._pet._submit_user_text(
                    text,
                    from_conversation=True,
                    conversation_request=request,
                ):
                    self.append_message("user", text, message_id=message_id)
                    self._input.clear()
                    self.set_busy(True)

            def set_busy(self, busy: bool):
                self._busy = bool(busy)
                self._send.setEnabled(not self._busy)
                self._stop.setEnabled(self._busy)
                self._status.setText("正在回复…" if self._busy else "可以继续问")

            def show_busy_notice(self):
                self._status.setText("上一条还在回复，稍等一下")

            def finish_reply(self, reply: str, request: dict | None = None):
                request = request if isinstance(request, dict) else {}
                target = str(request.get("target_message_id", ""))
                if request.get("mode") == "regenerate" and target:
                    if not self.update_message(target, reply):
                        self.append_message("pet", reply)
                else:
                    self.append_message("pet", reply)
                self.set_busy(False)
                self._input.setFocus()

            def generation_stopped(self):
                self._busy = False
                self._send.setEnabled(True)
                self._stop.setEnabled(False)
                self._status.setText("已停止，可以继续输入")
                self._input.setFocus()

            def stop_generation(self):
                if self._pet._stop_ai_generation():
                    self.generation_stopped()

            def copy_all(self):
                lines = []
                for item in self._messages:
                    role = str(item.get("role", "pet"))
                    message = str(item.get("text", ""))
                    lines.append(f"{'你' if role == 'user' else APP_NAME}：{message}")
                QtWidgets.QApplication.clipboard().setText("\n\n".join(lines))
                self._status.setText("已复制")

            def _clear_message_widgets(self):
                while self._message_layout.count() > 1:
                    item = self._message_layout.takeAt(0)
                    widget = item.widget()
                    if widget:
                        widget.hide()
                        widget.setParent(None)
                        widget.deleteLater()
                self._messages.clear()

            def new_session(self):
                if self._busy:
                    self._pet._stop_ai_generation()
                self._clear_message_widgets()
                self.append_message("pet", "新对话开始了，我在。", allow_regenerate=False)
                self.generation_stopped()
                self._status.setText("已新建对话，长期记忆仍保留")

            def clear_messages(self):
                self.new_session()

            def show_for_pet(self):
                self.show()
                screen = self._pet.app.primaryScreen().availableGeometry()
                x = int(self._pet.x - self.width() - 18)
                if x < screen.left():
                    x = int(self._pet.x + self._pet.pet_w + 18)
                x = max(screen.left(), min(x, screen.right() - self.width() + 1))
                y = max(screen.top(), min(int(self._pet.y), screen.bottom() - self.height() + 1))
                self.move(x, y)
                self.raise_()
                self.activateWindow()
                self._input.setFocus()

            def closeEvent(self, event):
                if self._allow_close:
                    event.accept()
                    return
                event.ignore()
                self.hide()

            def force_close(self):
                self._allow_close = True
                self.close()

        conversation = ConversationWindow()
        return conversation


class QtDesktopPet:
    """Transparent desktop pet window using Qt per-pixel alpha."""

    _time_greetings = DesktopPet._time_greetings
    _keyword_replies = DesktopPet._keyword_replies
    idle_lines = DesktopPet.idle_lines
    click_responses = DesktopPet.click_responses
    _reaction_rules = DesktopPet._reaction_rules
    _detect_reaction = DesktopPet._detect_reaction
    _smart_reply = DesktopPet._smart_reply
    _quick_reply = DesktopPet._quick_reply
    _reaction_reply_action = DesktopPet._reaction_reply_action
    _ai_wait_action = DesktopPet._ai_wait_action
    _update_ai_wait_action = DesktopPet._update_ai_wait_action
    _play_reply_motion = DesktopPet._play_reply_motion
    _update_blink = DesktopPet._update_blink
    _idle_breathe_active = DesktopPet._idle_breathe_active

    def __init__(
        self,
        renderer: PetRenderer,
        size: int,
        chat_history: ChatHistory | None = None,
        refresh_ms: int = 60,
        start_position: Tuple[int, int] | None = None,
        enable_debug: bool = False,
    ):
        from PySide6 import QtCore, QtGui, QtWidgets

        self._QtCore = QtCore
        self._QtGui = QtGui
        self._QtWidgets = QtWidgets
        self.renderer = renderer
        self.size = size
        self._debug = enable_debug
        self._window_engine = "qt"
        self.refresh_ms = max(8, min(refresh_ms, 100))
        self.action = "look"
        self.action_end = time.time() + 2.0
        self.last_time = time.time()
        self.elapsed = 0.0
        self._action_started_elapsed = 0.0
        self._edge_peek: dict | None = None
        self._last_mouse_track = 0.0
        self._last_pointer_track = 0.0
        self._last_idle_action = "look"
        self._next_mouse_watch_allowed = 0.0
        self._next_edge_peek_allowed = 0.0
        self._next_stretch_allowed = 0.0
        self._action_queue: list[tuple] = []
        self._active_sequence_label = ""
        self._current_sequence_caption = ""
        self._next_blink_at = time.time() + random.uniform(8.0, 16.0)
        self._blink_until = 0.0
        self._blink_started_elapsed = 0.0
        self._visual_action = self.action
        self._visual_prev_action: str | None = None
        self._visual_prev_frame: RendererFrame | None = None
        self._visual_transition_started = time.time()
        self._visual_transition_duration = 0.0
        self._visual_transition_masked = False
        self._last_render_frame: RendererFrame | None = None
        self._interaction_calm_until = 0.0
        self._lively_until = 0.0
        self._last_click_response_at = 0.0
        self._last_head_pat_at = 0.0
        self.dragging = False
        self._drag_global = None
        self._drag_press_global = None
        self._drag_window_origin = (0.0, 0.0)
        self._drag_action_started = False
        self._last_drag_chat_reposition = 0.0
        self._ai_thread: threading.Thread | None = None
        self._ai_result: dict | None = None
        self._pending_reaction: dict | None = None
        self._chat_visible = False
        self._chat_hide_token = 0
        self._frame_qimage = None
        self._frame_pixmap = None

        cfg = configparser.ConfigParser()
        cfg.read(_config_path(), encoding="utf-8-sig")
        self._auto_chat_enabled = _to_bool(cfg.get("DEFAULT", "AUTO_CHAT_ENABLED", fallback="true"), True)
        self._topmost = _to_bool(cfg.get("DEFAULT", "PET_TOPMOST", fallback="true"), True)
        self._window_alpha = _opacity_from_config(cfg, fallback=0.98)
        self._activity_mode = _activity_mode_from_config(cfg)
        self._motion_detail = _motion_detail_from_config(cfg)
        self._auto_big_actions = _to_bool(cfg.get("DEFAULT", "PET_AUTO_BIG_ACTIONS", fallback="false"), False)
        self._idle_breathe_enabled = _idle_breathe_from_config(cfg)
        self._chat_interval_min, self._chat_interval_max = _auto_chat_bounds(
            self._activity_mode,
            cfg.getint("DEFAULT", "AUTO_CHAT_INTERVAL_MIN", fallback=420),
            cfg.getint("DEFAULT", "AUTO_CHAT_INTERVAL_MAX", fallback=900),
        )
        (
            self._move_prob_x,
            self._move_prob_y,
            self._move_amount_x,
            self._move_amount_y,
        ) = _movement_profile(self._activity_mode, self._motion_detail)
        self.last_chat = time.time()
        self.next_chat = random.randint(self._chat_interval_min, self._chat_interval_max)
        self._chat_history = chat_history

        memory_enabled = _to_bool(cfg.get("DEFAULT", "MEMORY_ENABLED", fallback="true"), True)
        memory_path_text = cfg.get("DEFAULT", "MEMORY_PATH", fallback="pet_memory.json").strip() or "pet_memory.json"
        memory_recent_limit = cfg.getint("DEFAULT", "MEMORY_RECENT_TURNS", fallback=16)
        self._memory: ConversationMemory | None = None
        if memory_enabled:
            memory_path = Path(memory_path_text).expanduser()
            if not memory_path.is_absolute():
                memory_path = Path(_state_path(memory_path_text))
            self._memory = ConversationMemory(memory_path, recent_limit=memory_recent_limit)
        self._command_file = _command_file()
        self._status_file = _status_file()
        self._last_status_write = 0.0

        self.app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv[:1])
        self.app.setQuitOnLastWindowClosed(True)
        self.app.setApplicationName(APP_NAME)

        flags = QtCore.Qt.WindowType.FramelessWindowHint | QtCore.Qt.WindowType.Tool
        if self._topmost:
            flags |= QtCore.Qt.WindowType.WindowStaysOnTopHint
        self.win = _QtPetLabel(self)
        self.win.setWindowTitle(APP_NAME)
        self.win.setWindowFlags(flags)
        self.win.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True)
        self.win.setWindowOpacity(self._window_alpha)
        self._apply_macos_visibility(self.win)
        self.win.setStyleSheet("background: transparent;")

        self.chat_win = _QtChatBubble()
        self.chat_win.setWindowTitle(f"{APP_NAME}·聊天")
        self.chat_win.setWindowFlags(flags)
        self.chat_win.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True)
        show_without_activating = getattr(QtCore.Qt.WidgetAttribute, "WA_ShowWithoutActivating", None)
        if show_without_activating is not None:
            self.chat_win.setAttribute(show_without_activating, True)
        self.chat_win.setWindowOpacity(self._window_alpha)
        self._apply_macos_visibility(self.chat_win)
        self.chat_win.setFixedWidth(300)
        self.chat_win.hide()

        self.conversation_win = _QtConversationWindow(self)
        self._apply_macos_visibility(self.conversation_win)
        self.conversation_win.hide()

        self.menu = QtWidgets.QMenu()
        self.menu.addAction("AI 对话", lambda _checked=False: self._manual_chat())
        self.menu.addAction("摸摸头", lambda _checked=False: self._pet_head())
        self.action_menu = self.menu.addMenu("动作")
        self.action_menu.addAction("轻呼吸", lambda _checked=False: self._play_command_action("breathe"))
        self.action_menu.addAction("眨眨眼", lambda _checked=False: self._play_command_action("blink"))
        self.action_menu.addAction("开心一下", lambda _checked=False: self._play_command_action("happy"))
        self.action_menu.addAction("挥挥手", lambda _checked=False: self._play_command_action("wave"))
        self.action_menu.addAction("睡一会", lambda _checked=False: self._play_command_action("sleep"))
        self.action_menu.addAction("点点头", lambda _checked=False: self._play_command_action("nod"))
        self.action_menu.addAction("摇摇头", lambda _checked=False: self._play_command_action("shake"))
        self.action_menu.addAction("歪头看看", lambda _checked=False: self._play_command_action("curious"))
        self.action_menu.addAction("认真听", lambda _checked=False: self._play_command_action("listen"))
        self.action_menu.addAction("害羞一下", lambda _checked=False: self._play_command_action("shy"))
        self.action_menu.addAction("伸懒腰", lambda _checked=False: self._play_command_action("stretch"))
        self.action_menu.addAction("走两步", lambda _checked=False: self._play_command_action("walk"))
        self.action_menu.addSeparator()
        self.action_menu.addAction("随机互动", lambda _checked=False: self._play_command_sequence("random"))
        self.action_menu.addSeparator()
        self.action_menu.addAction("动作巡检", lambda _checked=False: self._play_command_sequence("patrol"))
        self.action_menu.addAction("打招呼", lambda _checked=False: self._play_command_sequence("greet"))
        self.action_menu.addAction("收到啦", lambda _checked=False: self._play_command_sequence("ack"))
        self.action_menu.addAction("慢慢呼吸", lambda _checked=False: self._play_command_sequence("calm_breathe"))
        self.action_menu.addAction("晚安", lambda _checked=False: self._play_command_sequence("goodnight"))
        self.menu.addAction("安静模式", lambda _checked=False: self._quiet_mode())
        self.menu.addAction("陪伴模式", lambda _checked=False: self._normal_mode())
        self.menu.addAction("活泼一下", lambda _checked=False: self._lively_mode())
        self.menu.addAction("站好", lambda _checked=False: self._stand_still())
        self.menu.addAction("回到屏幕内", lambda _checked=False: self._bring_back_to_screen())
        self.menu.addAction("控制面板", lambda _checked=False: self._open_control_panel())
        self.menu.addAction("退出", lambda _checked=False: self._quit())

        pos_name = "pet_position_mac.txt" if sys.platform == "darwin" else "pet_position.txt"
        self._pos_file = Path(_state_path(pos_name))
        saved = self._load_position()
        screen = self.app.primaryScreen().availableGeometry()
        self.screen_w = screen.width()
        self.screen_h = screen.height()
        self.pet_w = max(60, int(size * 0.6))
        self.pet_h = size + 6
        if start_position and len(start_position) == 2:
            self.x, self.y = start_position
        elif saved:
            self.x, self.y = saved
        else:
            self.x = max(0, self.screen_w - size - 60)
            self.y = max(0, self.screen_h - size - 120)
        self.x, self.y = self._clamp_to_screen(self.x, self.y, self.screen_w, self.screen_h, self.pet_w, self.pet_h)

        self._ai = AIChatClient(chat_history=chat_history, memory=self._memory)
        if self._ai.enabled:
            _print_with_utf8(f"[启动信息] DeepSeek AI：已开启 ({self._ai.model})")
        else:
            _print_with_utf8("[启动信息] DeepSeek AI：未配置，使用本地简易回复")
        if self._memory:
            _print_with_utf8(f"[启动信息] 本地记忆：已开启 ({self._memory.path})")
        else:
            _print_with_utf8("[启动信息] 本地记忆：未开启")
        _print_with_utf8(f"[启动信息] 主动说话：{'已开启' if self._auto_chat_enabled else '已关闭'}")
        _print_with_utf8(
            f"[启动信息] 外观动作：透明度={int(round(self._window_alpha * 100))}% "
            f"置顶={'开' if self._topmost else '关'} 模式={self._activity_mode} "
            f"细腻度={self._motion_detail} 自动大动作={'开' if self._auto_big_actions else '关'}"
        )

        self._draw_frame(0.0)
        self.win.move(int(self.x), int(self.y))
        self.win.show()
        self.win.raise_()
        _debug_log(self._debug, f"透明窗口：Qt模式 pos=({int(self.x)},{int(self.y)})")
        self._qt_timer = QtCore.QTimer()
        precise_timer = getattr(QtCore.Qt.TimerType, "PreciseTimer", None)
        if precise_timer is not None:
            self._qt_timer.setTimerType(precise_timer)
        self._qt_timer.timeout.connect(self._tick)
        self._qt_timer.start(self.refresh_ms)
        QtCore.QTimer.singleShot(800, self._startup_hello)

    def _apply_macos_visibility(self, widget):
        attr = getattr(self._QtCore.Qt.WidgetAttribute, "WA_MacAlwaysShowToolWindow", None)
        if attr is not None:
            try:
                widget.setAttribute(attr, True)
                _debug_log(self._debug, "macOS窗口保持显示: applied=1")
            except Exception:
                pass

    def _pil_to_pixmap(self, image, rgba_bytes: bytes | None = None):
        img = image if image.mode == "RGBA" else image.convert("RGBA")
        data = rgba_bytes if rgba_bytes is not None else img.tobytes("raw", "RGBA")
        qimg = self._QtGui.QImage(
            data,
            img.width,
            img.height,
            img.width * 4,
            self._QtGui.QImage.Format.Format_RGBA8888,
        ).copy()
        self._frame_qimage = qimg
        return self._QtGui.QPixmap.fromImage(qimg)

    def _load_position(self) -> Tuple[int, int] | None:
        if self._pos_file.exists():
            try:
                text = self._pos_file.read_text(encoding="utf-8").strip()
                parts = text.split(",")
                if len(parts) == 2:
                    return int(round(float(parts[0].strip()))), int(round(float(parts[1].strip())))
            except Exception:
                pass
        return None

    def _save_position(self):
        try:
            self._pos_file.parent.mkdir(parents=True, exist_ok=True)
            self._pos_file.write_text(f"{int(self.x)},{int(self.y)}", encoding="utf-8")
        except Exception:
            pass

    def _clamp_to_screen(
        self, x: int, y: int, width: int, height: int, width_hint: int, height_hint: int
    ) -> Tuple[int, int]:
        max_x = max(0, width - width_hint)
        max_y = max(0, height - height_hint)
        return (max(0, min(x, max_x)), max(0, min(y, max_y)))

    def _write_runtime_status(self, now: float):
        if now - self._last_status_write < 1.0:
            return
        self._last_status_write = now
        try:
            payload = {
                "running": True,
                "pid": os.getpid(),
                "updated_at": now,
                "renderer": getattr(self.renderer, "name", "unknown"),
                "window_engine": self._window_engine,
                "action": self.action,
                "mode": self._activity_mode,
                "motion_detail": self._motion_detail,
                "auto_big_actions": bool(self._auto_big_actions),
                "idle_breathe": bool(self._idle_breathe_enabled),
                "queue_count": len(self._action_queue),
                "sequence_label": self._active_sequence_label,
                "sequence_caption": self._current_sequence_caption,
                "chat_visible": bool(self._chat_visible),
                "ai_busy": bool(self._ai_thread is not None and self._ai_thread.is_alive()),
                "x": int(round(self.x)),
                "y": int(round(self.y)),
                "size": int(self.size),
            }
            tmp = self._status_file.with_suffix(".tmp")
            tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
            tmp.replace(self._status_file)
        except Exception:
            pass

    def _hold_action(self, action: str, seconds: float = 2.0):
        if action != self.action:
            self._action_started_elapsed = self.elapsed
        self.action = action
        self.action_end = time.time() + max(0.5, seconds)

    def _settle_after_interaction(self, seconds: float = 7.0, reset_idle_chat: bool = True):
        now = time.time()
        self._interaction_calm_until = max(self._interaction_calm_until, now + max(1.0, seconds))
        if reset_idle_chat:
            self.last_chat = now
            self.next_chat = random.randint(self._chat_interval_min + 90, self._chat_interval_max + 180)

    def _startup_hello(self):
        if _startup_greeting_due():
            _mark_startup_greeting_seen()
            self._show_chat("我在。", duration_ms=1800)
        self._settle_after_interaction(8.0)
        self._hold_action("look", 3.2)

    def _show_chat(self, text: str, duration_ms: int = 4500):
        duration_ms = _chat_duration_ms(text, duration_ms)
        self._settle_after_interaction(max(3.0, duration_ms / 1000.0 + 1.5))
        self._chat_hide_token += 1
        token = self._chat_hide_token
        self.chat_win.setText(text)
        self.chat_win.adjustSize()
        self._reposition_chat()
        self.chat_win.show()
        self.chat_win.raise_()
        self._chat_visible = True
        self._QtCore.QTimer.singleShot(duration_ms, lambda: self._hide_chat(token))

    def _hide_chat(self, token: int | None = None):
        if token is not None and token != self._chat_hide_token:
            return
        self.chat_win.hide()
        self._chat_visible = False
        self._after_chat_hidden()

    def _after_chat_hidden(self):
        if self.dragging or (self._ai_thread is not None and self._ai_thread.is_alive()):
            return
        self._settle_after_interaction(8.0)
        if self.action not in {"typing", "drag"}:
            self._hold_action("rest", 3.2)

    def _reposition_chat(self):
        self.chat_win.adjustSize()
        cw = self.chat_win.width()
        ch = self.chat_win.height()
        bx = int(self.x + self.pet_w // 2 - cw // 2)
        by = int(self.y - ch - 8)
        bx = max(0, min(bx, max(0, self.screen_w - cw)))
        by = max(0, min(by, max(0, self.screen_h - ch)))
        self.chat_win.move(bx, by)

    def _manual_chat(self):
        self._settle_after_interaction(10.0)
        self.conversation_win.show_for_pet()

    def _pet_head(self):
        now = time.time()
        if now - self._last_head_pat_at < 3.0:
            self._settle_after_interaction(6.0, reset_idle_chat=False)
            self._hold_action("look", 1.2)
            return
        self._last_head_pat_at = now
        self._edge_peek = None
        self._action_queue = _head_pat_steps()
        self._active_sequence_label = "摸摸头收到。"
        self._current_sequence_caption = ""
        self._settle_after_interaction(8.0)
        self._show_chat(random.choice(["嘿嘿。", "嗯嗯，在呢。", "摸摸头收到。", "好乖地待着。"]), duration_ms=3200)
        self._advance_action_queue()

    def _quiet_mode(self):
        self._action_queue.clear()
        self._lively_until = 0.0
        self._auto_chat_enabled = False
        self._activity_mode = "quiet"
        self._auto_big_actions = False
        (
            self._move_prob_x,
            self._move_prob_y,
            self._move_amount_x,
            self._move_amount_y,
        ) = _movement_profile("quiet", self._motion_detail)
        self.next_chat = 10 ** 9
        self._show_chat("好，我安静陪你。", duration_ms=3000)
        self._hold_action("look", 2.0)

    def _normal_mode(self):
        self._action_queue.clear()
        self._lively_until = 0.0
        self._auto_chat_enabled = True
        self._activity_mode = "normal"
        self._auto_big_actions = False
        self._chat_interval_min, self._chat_interval_max = _auto_chat_bounds("normal", 420, 900)
        (
            self._move_prob_x,
            self._move_prob_y,
            self._move_amount_x,
            self._move_amount_y,
        ) = _movement_profile("normal", self._motion_detail)
        self.last_chat = time.time()
        self.next_chat = random.randint(self._chat_interval_min, self._chat_interval_max)
        self._show_chat("好，我陪着你。", duration_ms=2600)
        self._hold_action("look", 2.8)

    def _lively_mode(self, duration_seconds: float = _TEMP_LIVELY_SECONDS):
        self._action_queue.clear()
        self._auto_chat_enabled = True
        self._activity_mode = "lively"
        self._auto_big_actions = True
        duration_seconds = max(2.0, min(float(duration_seconds), 600.0))
        self._lively_until = time.time() + duration_seconds
        self._chat_interval_min, self._chat_interval_max = _auto_chat_bounds("lively", 180, 420)
        (
            self._move_prob_x,
            self._move_prob_y,
            self._move_amount_x,
            self._move_amount_y,
        ) = _movement_profile("lively", self._motion_detail)
        cooldown_scale = _motion_cooldown_scale(self._motion_detail)
        self._next_mouse_watch_allowed = time.time() + 2.0 * cooldown_scale
        self._next_edge_peek_allowed = time.time() + 35.0 * cooldown_scale
        self._next_stretch_allowed = time.time() + 45.0 * cooldown_scale
        self.last_chat = time.time()
        self.next_chat = random.randint(self._chat_interval_min, self._chat_interval_max)
        self._show_chat("好，我活泼一会儿。", duration_ms=2600)
        self._hold_action("wave", 2.8)

    def _restore_companion_after_lively(self):
        self._lively_until = 0.0
        self._activity_mode = "normal"
        self._auto_big_actions = False
        self._chat_interval_min, self._chat_interval_max = _auto_chat_bounds("normal", 420, 900)
        (
            self._move_prob_x,
            self._move_prob_y,
            self._move_amount_x,
            self._move_amount_y,
        ) = _movement_profile("normal", self._motion_detail)
        self._edge_peek = None
        self.last_chat = time.time()
        self.next_chat = random.randint(self._chat_interval_min, self._chat_interval_max)
        _print_with_utf8("[状态信息] 临时活泼结束，已恢复陪伴模式")
        self._show_chat("我安静回来啦。", duration_ms=2200)
        self._hold_action("rest", 2.6)

    def _check_lively_timeout(self, now: float):
        if self._activity_mode == "lively" and self._lively_until and now >= self._lively_until:
            self._restore_companion_after_lively()

    def _stand_still(self):
        self._action_queue.clear()
        self._lively_until = 0.0
        self._activity_mode = "normal"
        self._auto_big_actions = False
        self._chat_interval_min, self._chat_interval_max = _auto_chat_bounds("normal", 420, 900)
        (
            self._move_prob_x,
            self._move_prob_y,
            self._move_amount_x,
            self._move_amount_y,
        ) = _movement_profile("normal", self._motion_detail)
        self._edge_peek = None
        self.last_chat = time.time()
        self.next_chat = random.randint(self._chat_interval_min, self._chat_interval_max)
        self._show_chat("好，我站好。", duration_ms=1800)
        self._hold_action("rest", 3.5)

    def _bring_back_to_screen(self):
        self._action_queue.clear()
        try:
            screen = self.app.primaryScreen().availableGeometry()
            self.screen_w = screen.width()
            self.screen_h = screen.height()
        except Exception:
            pass
        target_x = max(0, self.screen_w - self.pet_w - 60)
        target_y = max(0, self.screen_h - self.pet_h - 120)
        self.x, self.y = self._clamp_to_screen(
            target_x,
            target_y,
            self.screen_w,
            self.screen_h,
            self.pet_w,
            self.pet_h,
        )
        self._edge_peek = None
        self.win.move(int(self.x), int(self.y))
        self._save_position()
        if self._chat_visible:
            self._reposition_chat()
        self._settle_after_interaction(8.0)
        self._show_chat("我回来啦。", duration_ms=2200)
        self._hold_action("look", 2.4)

    def _play_command_action(self, pet_action: str):
        self._action_queue.clear()
        pet_action = pet_action.strip()
        durations = {
            "mouse_watch": 3.2,
            "edge_peek": 7.0,
            "sleep": 6.0,
            "stretch": 4.2,
            "glance": 3.2,
            "breathe": 5.0,
            "nod": 2.2,
            "shake": 2.2,
            "curious": 3.2,
            "shy": 3.0,
            "listen": 3.0,
            "look": 3.0,
            "talk": 2.6,
            "typing": 2.8,
            "happy": 2.5,
            "wink": 2.0,
            "blink": 0.5,
            "wave": 3.0,
            "walk": 4.5,
        }
        if pet_action not in durations:
            return
        manual_spec = _manual_action_sequence_for_action(pet_action)
        if manual_spec:
            self._edge_peek = None
            self._action_queue = _sequence_steps(manual_spec)
            self._active_sequence_label = str(manual_spec.get("label", ""))
            self._current_sequence_caption = ""
            self._settle_after_interaction(8.0)
            if self._active_sequence_label:
                self._show_chat(self._active_sequence_label, duration_ms=1800)
            self._advance_action_queue()
            return
        if pet_action == "edge_peek":
            if self.action != "edge_peek":
                self._action_started_elapsed = self.elapsed
            self.action = "edge_peek"
            self.action_end = time.time() + durations[pet_action]
            self._start_edge_peek()
        else:
            self._edge_peek = None
            self._hold_action(pet_action, durations[pet_action])
        label = {
            "mouse_watch": "我在看。",
            "edge_peek": "探头看看。",
            "sleep": "困一下。",
            "stretch": "伸个懒腰。",
            "glance": "看你一眼。",
            "breathe": "我在这里。",
            "nod": "嗯嗯。",
            "shake": "不是这样。",
            "curious": "嗯？",
            "shy": "有点不好意思。",
            "listen": "我在听。",
            "blink": "眨一下。",
            "talk": "我在听。",
            "typing": "我想一下。",
        }.get(pet_action)
        if label:
            self._show_chat(label, duration_ms=1800)

    def _advance_action_queue(self) -> bool:
        if not self._action_queue:
            self._active_sequence_label = ""
            self._current_sequence_caption = ""
            return False
        step = self._action_queue.pop(0)
        pet_action = str(step[0])
        seconds = float(step[1])
        caption = str(step[2]).strip() if len(step) >= 3 else ""
        self._current_sequence_caption = caption
        if caption:
            self._show_chat(f"巡检：{caption}", duration_ms=max(900, min(1600, int(seconds * 700))))
        if pet_action == "edge_peek":
            if self.action != "edge_peek":
                self._action_started_elapsed = self.elapsed
            self.action = "edge_peek"
            self.action_end = time.time() + max(0.5, seconds)
            self._start_edge_peek()
        else:
            self._edge_peek = None
            self._hold_action(pet_action, seconds)
        return True

    def _play_command_sequence(self, sequence_name: str):
        spec = _resolve_command_sequence(sequence_name)
        if not spec:
            return
        steps = _sequence_steps(spec)
        total_seconds = sum(float(step[1]) for step in steps)
        self._edge_peek = None
        self._action_queue = steps
        self._active_sequence_label = str(spec["label"])
        self._current_sequence_caption = ""
        self._settle_after_interaction(total_seconds + 5.0)
        self._show_chat(str(spec["label"]), duration_ms=2200)
        self._advance_action_queue()

    def _poll_external_command(self):
        if self._ai_thread is not None and self._ai_thread.is_alive():
            return
        command = _read_pet_command(self._command_file)
        if not command:
            return
        action = str(command.get("action", "")).strip()
        if action == "chat":
            text = str(command.get("text", "")).strip()
            if text:
                self._submit_user_text(text)
        elif action == "open_chat":
            self._manual_chat()
        elif action == "play_action":
            self._play_command_action(str(command.get("pet_action", command.get("text", ""))))
        elif action == "play_sequence":
            self._play_command_sequence(str(command.get("sequence", command.get("text", ""))))
        elif action == "play_reply_motion":
            reaction_name = str(command.get("reaction", command.get("text", "neutral"))).strip().lower() or "neutral"
            reply = str(command.get("reply", "我在。"))
            self._play_reply_motion({"name": reaction_name}, reply)
        elif action == "play_wait_motion":
            reaction_name = str(command.get("reaction", command.get("text", "neutral"))).strip().lower() or "neutral"
            self._edge_peek = None
            self._action_queue = list(_wait_motion_steps_for_reaction(reaction_name))
            self._active_sequence_label = _wait_motion_label(reaction_name)
            self._current_sequence_caption = ""
            self._settle_after_interaction(8.0)
            self._advance_action_queue()
        elif action == "reset_position":
            self._bring_back_to_screen()
        elif action == "quit":
            self._quit()
            return
        elif action == "clear_memory":
            self._clear_memory()
        elif action == "reload_memory":
            if self._memory:
                self._memory.reload()
                self._show_chat("记忆已更新。", duration_ms=1800)
        elif action == "set_runtime_mode":
            mode = str(command.get("mode", command.get("text", ""))).strip().lower()
            if mode == "quiet":
                self._quiet_mode()
            elif mode in {"normal", "companion"}:
                self._normal_mode()
            elif mode in {"lively", "active"}:
                try:
                    duration = float(command.get("duration", command.get("seconds", _TEMP_LIVELY_SECONDS)))
                except Exception:
                    duration = _TEMP_LIVELY_SECONDS
                self._lively_mode(duration)
            elif mode in {"stand", "still", "stable"}:
                self._stand_still()

    def _submit_user_text(
        self,
        text: str,
        from_conversation: bool = False,
        conversation_request: dict | None = None,
    ) -> bool:
        if not text.strip():
            return False
        if self._ai_thread is not None and self._ai_thread.is_alive():
            if from_conversation:
                self.conversation_win.show_busy_notice()
            return False
        self._action_queue.clear()
        if self._memory:
            self._memory.learn_from_user(text)
            self._memory.save()
        reaction = self._detect_reaction(text)
        self._pending_reaction = reaction
        self._settle_after_interaction(12.0)
        self._show_chat(f"「{text}」", duration_ms=1800)
        self._hold_action("look", 2.6)
        quick_reply = self._quick_reply(text, reaction)
        result_holder = {
            "reply": None,
            "user": text,
            "reaction": reaction,
            "quick_reply": quick_reply,
            "quick_shown": False,
            "from_conversation": bool(from_conversation),
            "conversation_request": dict(conversation_request or {}),
            "cancelled": False,
            "started_at": time.time(),
        }

        def _show_quick_reply():
            if self._ai_result is result_holder and self._ai_thread is not None and self._ai_thread.is_alive():
                result_holder["quick_shown"] = True
                self._show_chat(quick_reply, duration_ms=6500)
                self._update_ai_wait_action(time.time())

        self._QtCore.QTimer.singleShot(650, _show_quick_reply)

        def _call_ai():
            try:
                reply = None
                if self._ai.enabled:
                    reply = self._ai.chat(text, reaction_hint=reaction.get("hint"))
                if not reply:
                    reply = quick_reply
                result_holder["reply"] = reply
            except Exception as e:
                print(f"[AI Error] {e}")
                result_holder["reply"] = quick_reply

        self._ai_thread = threading.Thread(target=_call_ai, daemon=True)
        self._ai_result = result_holder
        self._ai_thread.start()
        return True

    def _stop_ai_generation(self) -> bool:
        result = self._ai_result
        if result is None and self._ai_thread is None:
            return False
        if isinstance(result, dict):
            result["cancelled"] = True
        # urllib cannot safely terminate an in-flight socket from another thread.
        # Detach it so its eventual result is ignored and the user can continue immediately.
        self._ai_result = None
        self._ai_thread = None
        self._pending_reaction = None
        self._action_queue.clear()
        self._settle_after_interaction(4.0)
        self._hold_action("rest", 2.0)
        return True

    def _say_something(self):
        line = random.choice(self.idle_lines)
        self._show_chat(line, duration_ms=random.randint(3000, 6000))
        self._hold_action("rest", 2.2)

    def _idle_chat_tick(self):
        if not self._auto_chat_enabled:
            return
        now = time.time()
        if now < self._interaction_calm_until:
            return
        if not self._chat_visible and now - self.last_chat >= self.next_chat:
            self.last_chat = now
            self.next_chat = random.randint(self._chat_interval_min, self._chat_interval_max)
            self._say_something()

    def _set_next_action(self):
        now = time.time()
        if now < self._interaction_calm_until:
            next_action = "look" if self._chat_visible else "rest"
            if next_action != self.action:
                self._action_started_elapsed = self.elapsed
            self.action = next_action
            self._edge_peek = None
            self.action_end = now + random.uniform(8.0, 14.0)
            return
        next_action = "rest"
        base = random.uniform(22.0, 38.0)
        for _ in range(10):
            candidate, candidate_base = _idle_action_for_mode(self._activity_mode, self._motion_detail)
            if candidate in _MAJOR_IDLE_ACTIONS and not self._auto_big_actions:
                continue
            if candidate == self._last_idle_action and candidate not in {"rest", "look"}:
                continue
            if candidate == "mouse_watch" and now < self._next_mouse_watch_allowed:
                continue
            if candidate == "edge_peek" and now < self._next_edge_peek_allowed:
                continue
            if candidate == "stretch" and now < self._next_stretch_allowed:
                continue
            next_action, base = candidate, candidate_base
            break
        if next_action != self.action:
            self._action_started_elapsed = self.elapsed
        self.action = next_action
        self._last_idle_action = self.action
        cooldown_scale = _motion_cooldown_scale(self._motion_detail)
        if self.action == "edge_peek":
            self._start_edge_peek()
            self._next_edge_peek_allowed = now + random.uniform(120.0, 190.0) * cooldown_scale
        else:
            self._edge_peek = None
        if self.action == "mouse_watch":
            self._next_mouse_watch_allowed = now + random.uniform(24.0, 42.0) * cooldown_scale
        if self.action == "stretch":
            self._next_stretch_allowed = now + random.uniform(150.0, 240.0) * cooldown_scale
        self.action_end = time.time() + max(1.5, base * _motion_duration_scale(self._motion_detail))

    def _start_edge_peek(self):
        left_dist = max(0.0, float(self.x))
        right_dist = max(0.0, float(self.screen_w - (self.x + self.pet_w)))
        edge_threshold = max(120.0, self.pet_w * 1.5)
        if min(left_dist, right_dist) <= edge_threshold:
            side = "left" if left_dist <= right_dist else "right"
            target_x = -int(self.pet_w * 0.14) if side == "left" else int(self.screen_w - self.pet_w * 0.86)
        else:
            direction = -1 if self.x < self.screen_w / 2 else 1
            target_x = int(self.x + direction * min(10.0, self.pet_w * 0.08))
        self._edge_peek = {
            "home": (float(self.x), float(self.y)),
            "target": (float(target_x), float(max(0, min(self.y, self.screen_h - self.pet_h)))),
            "return_at": time.time() + random.uniform(4.8, 6.4),
        }

    def _move_toward(self, target_x: float, target_y: float, rate: float = 0.08):
        self.x += (target_x - self.x) * rate
        self.y += (target_y - self.y) * rate

    def _track_mouse(self):
        now = time.time()
        if now - self._last_pointer_track < 0.028:
            return
        self._last_pointer_track = now
        pos = self._QtGui.QCursor.pos()
        px = pos.x()
        py = pos.y()
        cx = self.x + self.pet_w / 2
        cy = self.y + self.pet_h * 0.30
        dx = px - cx
        dy = py - cy
        distance = math.hypot(dx, dy)
        gaze_limit = max(420.0, self.pet_h * 2.4)
        if distance > gaze_limit:
            self.renderer.set_pointer_direction(0.0, 0.0)
            return
        self.renderer.set_pointer_direction(
            max(-1.0, min(1.0, dx / max(160.0, self.pet_w * 1.35))),
            max(-1.0, min(1.0, dy / max(160.0, self.pet_h * 0.95))),
        )
        if self._activity_mode != "lively":
            return
        if self._motion_detail == "stable" and not self._auto_big_actions:
            return
        if now < self._interaction_calm_until:
            return
        if now < self._next_mouse_watch_allowed:
            return
        if now - self._last_mouse_track < 0.35:
            return
        self._last_mouse_track = now
        distance_limit = 220 if self._activity_mode == "lively" else 170
        if distance > distance_limit:
            return
        if self.action in {"rest", "look", "glance"}:
            if self.action != "mouse_watch":
                self._action_started_elapsed = self.elapsed
            self.action = "mouse_watch"
            self.action_end = max(self.action_end, now + (1.6 if self._activity_mode == "lively" else 1.2))
            cooldown_scale = _motion_cooldown_scale(self._motion_detail)
            cooldown_min, cooldown_max = (16.0, 28.0) if self._activity_mode == "lively" else (30.0, 46.0)
            self._next_mouse_watch_allowed = now + random.uniform(cooldown_min, cooldown_max) * cooldown_scale

    def _qt_global_pos(self, event):
        if hasattr(event, "globalPosition"):
            return event.globalPosition().toPoint()
        return event.globalPos()

    def _qt_mouse_press(self, event):
        if event.button() == self._QtCore.Qt.MouseButton.LeftButton:
            self.dragging = False
            self._drag_global = self._qt_global_pos(event)
            self._drag_press_global = self._drag_global
            self._drag_window_origin = (float(self.x), float(self.y))
            self._drag_action_started = False
            self._last_drag_chat_reposition = 0.0
            event.accept()

    def _qt_mouse_move(self, event):
        if self._drag_press_global is None:
            return
        now_pos = self._qt_global_pos(event)
        diff = now_pos - self._drag_press_global
        if abs(diff.x()) > 3 or abs(diff.y()) > 3:
            self.dragging = True
        if self.dragging:
            self.x = self._drag_window_origin[0] + diff.x()
            self.y = self._drag_window_origin[1] + diff.y()
            self.x, self.y = self._clamp_to_screen(self.x, self.y, self.screen_w, self.screen_h, self.pet_w, self.pet_h)
            if not self._drag_action_started:
                self._edge_peek = None
                self._action_queue.clear()
                self._hold_action("drag", 0.8)
                self._drag_action_started = True
            self.win.move(int(round(self.x)), int(round(self.y)))
            now = time.time()
            if self._chat_visible and now - self._last_drag_chat_reposition > 0.035:
                self._reposition_chat()
                self._last_drag_chat_reposition = now
        self._drag_global = now_pos
        event.accept()

    def _qt_mouse_release(self, event):
        if event.button() != self._QtCore.Qt.MouseButton.LeftButton:
            return
        if not self.dragging:
            now = time.time()
            self._settle_after_interaction(8.0)
            if now - self._last_click_response_at < 2.0:
                self._hold_action("look", 1.6)
                self.dragging = False
                self._drag_global = None
                self._drag_press_global = None
                event.accept()
                return
            self._last_click_response_at = now
            self._show_chat(random.choice(self.click_responses), duration_ms=3500)
            self._edge_peek = None
            self._action_queue = [("happy", 1.4), ("rest", 1.8)]
            self._active_sequence_label = ""
            self._current_sequence_caption = ""
            self._advance_action_queue()
        else:
            if self._chat_visible:
                self._reposition_chat()
            self._save_position()
        self.dragging = False
        self._drag_action_started = False
        self._drag_global = None
        self._drag_press_global = None
        event.accept()

    def _show_menu(self, global_pos):
        exec_menu = getattr(self.menu, "exec", None) or getattr(self.menu, "exec_", None)
        if exec_menu:
            exec_menu(global_pos)

    def _open_control_panel(self):
        try:
            _open_control_panel()
            self._show_chat("控制面板打开啦。", duration_ms=1800)
        except Exception as exc:
            self._show_chat(f"控制面板打不开：{exc}", duration_ms=4500)

    def _update_movement(self):
        self._track_mouse()
        if self.dragging:
            return
        if self._edge_peek:
            target_x, target_y = self._edge_peek["target"]
            if time.time() >= self._edge_peek["return_at"]:
                target_x, target_y = self._edge_peek["home"]
                self._move_toward(target_x, target_y, rate=0.045)
                if abs(self.x - target_x) < 3 and abs(self.y - target_y) < 3:
                    self._edge_peek = None
                    if self.action != "look":
                        self._action_started_elapsed = self.elapsed
                    self.action = "look"
                    self.action_end = time.time() + random.uniform(1.0, 2.0)
            else:
                self._move_toward(target_x, target_y, rate=0.05)
            self.x = max(-int(self.pet_w * 0.40), min(self.x, self.screen_w - int(self.pet_w * 0.60)))
            self.y = max(0, min(self.y, self.screen_h - self.pet_h))
            self.win.move(int(round(self.x)), int(round(self.y)))
            if self._chat_visible:
                self._reposition_chat()
            return
        if self._move_prob_x <= 0 and self._move_prob_y <= 0:
            return
        if random.random() < self._move_prob_x:
            self.x += random.uniform(-self._move_amount_x, self._move_amount_x)
        if random.random() < self._move_prob_y:
            self.y += random.uniform(-self._move_amount_y, self._move_amount_y)
        self.x, self.y = self._clamp_to_screen(self.x, self.y, self.screen_w, self.screen_h, self.pet_w, self.pet_h)
        self.win.move(int(round(self.x)), int(round(self.y)))
        if self._chat_visible:
            self._reposition_chat()

    def _render_with_transition(self, render_action: str, delta: float, action_elapsed_override: float | None = None) -> RendererFrame:
        now = time.time()
        if render_action != self._visual_action:
            self._visual_prev_frame = self._last_render_frame
            self._visual_prev_action = self._visual_action
            self._visual_action = render_action
            self._visual_transition_started = now
            self._visual_transition_duration = _visual_transition_seconds(self._visual_prev_action, render_action)
            self._visual_transition_masked = _use_masked_visual_transition(self._visual_prev_action, render_action)

        action_elapsed = (
            max(0.0, action_elapsed_override)
            if action_elapsed_override is not None
            else max(0.0, self.elapsed - self._action_started_elapsed)
        )
        frame = self.renderer.render(render_action, action_elapsed, delta)
        if not self._visual_prev_action or self._visual_transition_duration <= 0:
            return frame

        progress = (now - self._visual_transition_started) / max(0.01, self._visual_transition_duration)
        if progress >= 1.0:
            self._visual_prev_action = None
            self._visual_prev_frame = None
            return frame
        if "drag" in {render_action, self._visual_prev_action}:
            return frame

        prev_frame = self._visual_prev_frame or self.renderer.render(self._visual_prev_action, action_elapsed, delta)
        if prev_frame.image.size != frame.image.size:
            return frame
        alpha = max(0.0, min(1.0, progress))
        alpha = alpha * alpha * (3.0 - 2.0 * alpha)
        if _use_pose_switch_transition(self._visual_prev_action, render_action):
            blended = _pose_switch_transition_images(
                prev_frame.image,
                frame.image,
                alpha,
                self._visual_prev_action,
                render_action,
            )
        else:
            blended = _blend_transition_images(
                prev_frame.image,
                frame.image,
                alpha,
                bool(self._visual_transition_masked),
            )
        return RendererFrame(blended, offset_x=frame.offset_x, offset_y=frame.offset_y)

    def _draw_frame(self, delta: float):
        self.elapsed += delta
        render_action = "drag" if self.dragging else self.action
        if render_action == "rest" and self._idle_breathe_active():
            render_action = "breathe"
        action_elapsed_override = None
        if not self.dragging and time.time() < self._blink_until:
            render_action = "blink"
            action_elapsed_override = max(0.0, self.elapsed - self._blink_started_elapsed)
        frame = self._render_with_transition(render_action, delta, action_elapsed_override)
        self._last_render_frame = frame
        clean_image = frame.image if frame.image.mode == "RGBA" else frame.image.convert("RGBA")
        rgba_bytes = clean_image.tobytes("raw", "RGBA")
        if rgba_bytes == getattr(self, "_last_frame_rgba", None):
            return
        self._last_frame_rgba = rgba_bytes
        pixmap = self._pil_to_pixmap(clean_image, rgba_bytes)
        self._frame_pixmap = pixmap
        self.win.setPixmap(pixmap)
        if self.win.size() != pixmap.size():
            self.win.resize(pixmap.size())
        self.pet_w = max(60, pixmap.width())
        self.pet_h = max(60, pixmap.height())

    def _tick(self):
        now = time.time()
        delta = now - self.last_time
        self.last_time = now
        if self._ai_thread is not None and self._ai_thread.is_alive():
            self._update_ai_wait_action(now)
        elif self._ai_result is not None:
            if self._ai_thread is None or not self._ai_thread.is_alive():
                reply = self._ai_result.get("reply")
                if reply:
                    user_text = self._ai_result.get("user", "")
                    reaction = self._ai_result.get("reaction") or self._pending_reaction
                    quick_reply = self._ai_result.get("quick_reply", "")
                    quick_shown = self._ai_result.get("quick_shown", False)
                    request = self._ai_result.get("conversation_request") or {}
                    if self._memory:
                        if request.get("mode") == "regenerate":
                            self._memory.replace_latest_turn(user_text, reply)
                        else:
                            self._memory.add_turn(user_text, reply)
                    if self._ai_result.get("from_conversation"):
                        self.conversation_win.finish_reply(reply, request=request)
                    if not (quick_shown and reply.strip() == str(quick_reply).strip()):
                        self._show_chat(reply, duration_ms=6000)
                        self._play_reply_motion(reaction, reply)
                    else:
                        self._play_reply_motion(reaction, reply)
                    self.last_chat = now
                    self.next_chat = random.randint(self._chat_interval_min + 60, self._chat_interval_max + 60)
                self._ai_result = None
                self._ai_thread = None
                self._pending_reaction = None
        self._check_lively_timeout(now)
        if now >= self.action_end:
            if not self._advance_action_queue():
                self._set_next_action()
        self._update_movement()
        self._update_blink(now)
        self._draw_frame(delta)
        self._write_runtime_status(now)
        self._poll_external_command()
        if self._ai_thread is None:
            self._idle_chat_tick()

    def _clear_memory(self):
        if not self._memory:
            self._show_chat("现在没有开启记忆。", duration_ms=2500)
            return
        self._memory.clear()
        self._show_chat("记忆清空啦。", duration_ms=2500)

    def _quit(self):
        self._save_position()
        self.conversation_win.force_close()
        self.chat_win.close()
        self.win.close()
        self.app.quit()

    def run(self):
        result = self.app.exec()
        _print_with_utf8("[退出信息] Qt 主循环已结束")
        return result


def render_preview(
    output: Path,
    action: str,
    size: int,
    mode: str = "auto",
    source_dir: str = None,
    bibi_dir: str = None,
    model_dir: str = None,
    live2d_dir: str = None,
):
    renderer = PetRenderer(
        mode=mode,
        size=size,
        source_dir=source_dir,
        bibi_dir=bibi_dir,
        model_dir=model_dir,
        live2d_dir=live2d_dir,
    )
    frame = renderer.render(action=action, t=time.time(), delta=0)
    out = output
    out.parent.mkdir(parents=True, exist_ok=True)
    frame.image.save(str(out), "PNG")


def parse_args():
    p = argparse.ArgumentParser(description=f"{APP_NAME}启动器")
    p.add_argument(
        "--renderer",
        default="auto",
        choices=["auto", "live2d", "puppet", "bibi", "source", "model_gf"],
        help="选择角色来源",
    )
    p.add_argument("--size", type=int, default=0, help="角色显示高度（默认读取 pet_config.ini）")
    p.add_argument("--preview", type=str, help="导出一张预览图，例如 C:\temp\preview.png")
    p.add_argument("--preview-action", default="look", dest="preview_action")
    p.add_argument("--fps", type=int, default=0, help="每秒帧率（默认60，越高越顺但越容易频繁重绘）")
    p.add_argument("--ultra-smooth", action="store_true", help="超丝滑 120fps")
    p.add_argument("--extra-smooth", action="store_true", help="更丝滑 75fps")
    p.add_argument("--no-chat", action="store_true", help="关闭自动提醒")
    p.add_argument("--no-reminder", action="store_true", help="关闭自动提醒")
    p.add_argument("--no-ai", action="store_true", help="关闭AI回复")
    p.add_argument("--source-dir", type=str, nargs="?", const="", default=None, help="指定source目录")
    p.add_argument("--bibi-dir", type=str, nargs="?", const="", default=None, help="指定bibi目录")
    p.add_argument("--model-dir", type=str, nargs="?", const="", default=None, help="指定model_gf目录")
    p.add_argument("--live2d-dir", type=str, nargs="?", const="", default=None, help="指定Live2D模型目录")
    p.add_argument("--debug", action="store_true", help="输出启动调试信息")
    p.add_argument("--chat-log", type=str, nargs="?", const="", default=None, help="导入聊天记录文件路径（txt/json/html 文件/目录/zip）")
    p.add_argument("--chat-self", type=str, nargs="?", const="", default=None, help="聊天记录里你自己的名字")
    p.add_argument("--chat-partner", type=str, nargs="?", const="", default=None, help="聊天记录里对方名字")
    p.add_argument("--x", type=int, default=None, help="初始X坐标")
    p.add_argument("--y", type=int, default=None, help="初始Y坐标")
    p.add_argument("--force-visible", action="store_true", help="禁用透明窗口，方便Mac排查显示问题")
    p.add_argument("--tk", action="store_true", help="强制使用Tk窗口")
    p.add_argument("--control-panel", action="store_true", help=f"打开{APP_NAME}控制面板")
    return p.parse_args()

def main():
    try:
        sys.stdout.reconfigure(encoding="utf-8", errors="replace")
        sys.stderr.reconfigure(encoding="utf-8", errors="replace")
    except Exception:
        pass

    args = parse_args()
    if args.control_panel:
        import control_panel

        return control_panel.main()
    if not _ensure_vendor_pillow():
        print("运行前请先安装 Pillow：pip install pillow")
        return 1

    enable_debug = args.debug or _to_bool(os.getenv("PET_DEBUG"), False)
    force_visible = args.force_visible or _to_bool(os.getenv("PET_FORCE_VISIBLE"), False)
    force_tk = args.tk or _to_bool(os.getenv("PET_FORCE_TK"), False)

    _debug_log(enable_debug, f"启动参数: {vars(args)}")

    cfg = configparser.ConfigParser()
    cfg.read(_config_path(), encoding="utf-8-sig")

    # Resolve renderer selection with config fallback
    render_mode = args.renderer
    if render_mode == "auto":
        render_mode = cfg.get("DEFAULT", "PREFERRED_RENDERER", fallback="auto").lower().strip()
        if render_mode not in {"auto", "live2d", "puppet", "bibi", "source", "model_gf"}:
            render_mode = "auto"

    live2d_dir = _normalize_path(
        args.live2d_dir
        or os.getenv(
            "PET_LIVE2D_DIR",
            cfg.get("DEFAULT", "LIVE2D_MODEL_DIR", fallback="").strip(),
        )
        or _image_path("bibi", "live2d")
    )
    live2d_status = inspect_live2d_model(live2d_dir)
    if render_mode == "live2d" and not Live2DRenderer.available(live2d_dir):
        reason = (
            ", ".join(live2d_status.missing_files)
            if not live2d_status.model_ready
            else "缺少Live2D运行时桥接模块"
        )
        _print_with_utf8(f"[启动信息] Live2D暂不可用（{reason}），自动使用PNG角色")
        render_mode = "bibi"

    # Resolve model_gf directory. Prefer env/config and fallback to assets/model_gf_clean when exists.
    model_dir = _normalize_path(
        args.model_dir
        or os.getenv(
            "PET_MODEL_DIR",
            cfg.get("DEFAULT", "MODEL_GF_DIR", fallback="").strip(),
        )
    )
    if model_dir is None:
        cleaned = _normalize_path(_image_path("assets", "model_gf_clean"))
        source = _normalize_path(_image_path("assets", "model_gf"))
        model_dir = cleaned or source or None
    _debug_log(enable_debug, f"模型目录解析: {model_dir}")

    # Read size from config when the command line leaves the parser default unchanged.
    if args.size <= 0:
        size = cfg.getint("DEFAULT", "PET_SIZE", fallback=160)
    else:
        size = args.size

    if render_mode == "live2d":
        role_dir = live2d_dir
    elif render_mode in {"bibi", "puppet"}:
        role_dir = args.bibi_dir or _image_path("bibi")
    else:
        role_dir = model_dir
    print(f"[启动信息] 当前渲染器={render_mode} 目标尺寸={size} 角色目录={role_dir}")
    auto_chat_enabled = _to_bool(cfg.get("DEFAULT", "AUTO_CHAT_ENABLED", fallback="true"), True)
    if args.no_chat or args.no_reminder or not auto_chat_enabled:
        print("[启动信息] 自动提醒：已关闭")
    else:
        print("[启动信息] 自动提醒：已开启")
    if args.no_ai:
        print("[启动信息] AI回复：已关闭")

    has_renderer = PetRenderer.available(
        render_mode,
        source_dir=args.source_dir,
        bibi_dir=args.bibi_dir,
        model_dir=model_dir,
        live2d_dir=live2d_dir,
    )
    _debug_log(
        enable_debug,
        f"渲染器可用性检查: mode={render_mode}, available={has_renderer}",
    )
    if not has_renderer:
        print("没有可用角色素材，请放入 bibi、assets/source 或 assets/model_gf")
        return 1

    # Chat-history configuration
    chat_log_path = (
        args.chat_log
        or cfg.get("DEFAULT", "CHAT_LOG_PATH", fallback="").strip()
    )
    chat_self_name = args.chat_self or cfg.get("DEFAULT", "CHAT_SELF_NAME", fallback="").strip()
    chat_partner_name = args.chat_partner or cfg.get("DEFAULT", "CHAT_PARTNER_NAME", fallback="").strip()

    chat_history = None
    if chat_log_path:
        try:
            chat_history = ChatHistory(
                chat_log_path,
                self_name=chat_self_name,
                partner_name=chat_partner_name,
            )
            if chat_history.enabled:
                print(
                    f"导入聊天记录: {chat_log_path}，{chat_history.summary()}"
                    f" 对方默认识别为：{chat_history.partner_name}"
                )
            else:
                print(f"聊天记录导入未命中：{chat_log_path}")
                print(
                    "可能原因：文件不是常见导出格式，或编码异常。"
                    "示例支持格式：\n"
                    "  张三: 你好\n"
                    "  [2026-01-01 10:00:00] 张三: 你好\n"
                    "  10-01 10:00 张三 -> 你好\n"
                )
                print(f"当前解析结果：{chat_history.summary()}")
        except Exception as exc:
            print(f"加载聊天记录失败: {exc}")
            chat_history = None

    if args.preview:
        render_preview(
            Path(args.preview),
            action=args.preview_action,
            size=size,
            mode=render_mode,
            source_dir=args.source_dir,
            bibi_dir=args.bibi_dir,
            model_dir=model_dir,
            live2d_dir=live2d_dir,
        )
        print(f"预览图已保存: {args.preview}")
        return 0

    if args.fps and args.fps > 0:
        fps = args.fps
    elif args.ultra_smooth:
        fps = 120
    elif args.extra_smooth:
        fps = 75
    else:
        fps = 60

    if fps < 15:
        fps = 15
    refresh_ms = max(12, 1000 // fps)
    print(f"[启动信息] 刷新率={fps}fps")
    start_pos = None
    if args.x is not None and args.y is not None:
        start_pos = (int(args.x), int(args.y))
    try:
        renderer = PetRenderer(
            mode=render_mode,
            size=size,
            source_dir=args.source_dir,
            bibi_dir=args.bibi_dir,
            model_dir=model_dir,
            live2d_dir=live2d_dir,
        )
        prefer_qt = sys.platform.startswith("win") or sys.platform == "darwin"
        if prefer_qt and not force_visible and not force_tk:
            try:
                pet = QtDesktopPet(
                    renderer,
                    size,
                    chat_history=chat_history,
                    refresh_ms=refresh_ms,
                    start_position=start_pos,
                    enable_debug=enable_debug,
                )
                _print_with_utf8("[启动信息] 透明窗口：Qt模式")
            except Exception as qt_exc:
                _print_with_utf8(f"[启动信息] Qt窗口启动失败，退回Tk窗口：{qt_exc}")
                pet = DesktopPet(
                    renderer,
                    size,
                    chat_history=chat_history,
                    refresh_ms=refresh_ms,
                    start_position=start_pos,
                    enable_debug=enable_debug,
                    force_visible=(sys.platform == "darwin"),
                )
        else:
            pet = DesktopPet(
                renderer,
                size,
                chat_history=chat_history,
                refresh_ms=refresh_ms,
                start_position=start_pos,
                enable_debug=enable_debug,
                force_visible=force_visible or (sys.platform == "darwin" and force_tk),
            )
        if args.no_chat or args.no_reminder:
            pet.next_chat = 10 ** 9  # disable auto chat
        if args.no_ai:
            pet._ai.enabled = False
        _write_pid_file()
        atexit.register(_remove_pid_file)
        pet.run()
    except Exception as exc:
        print(f"启动{APP_NAME}失败: {exc}")
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())



