#!/usr/bin/env python3
"""A local photo culling app with a browser-based live image viewer.

Pick a folder of photos, view the actual image in a large local browser view,
and copy originals into Liked, Rejected, or Maybe folders with one click.
"""

from __future__ import annotations

import html
import hashlib
import json
import mimetypes
import os
import socket
import secrets
import shutil
import smtplib
import ssl
import struct
import subprocess
import sys
import tempfile
import threading
import time
import urllib.parse
import urllib.request
import webbrowser
from dataclasses import dataclass
from email.message import EmailMessage
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path


APP_TITLE = "定帧 · 极简版"

PHOTO_EXTENSIONS = {
    ".jpg",
    ".jpeg",
    ".jpe",
    ".png",
    ".gif",
    ".webp",
    ".heic",
    ".heif",
    ".raw",
    ".dng",
    ".cr2",
    ".cr3",
    ".nef",
    ".nrw",
    ".arw",
    ".srf",
    ".sr2",
    ".raf",
    ".rw2",
    ".tif",
    ".tiff",
    ".tng",
    ".orf",
    ".srw",
    ".pef",
    ".erf",
    ".3fr",
    ".fff",
    ".iiq",
    ".mos",
    ".kdc",
    ".dcr",
    ".x3f",
}

BROWSER_NATIVE_EXTENSIONS = {".jpg", ".jpeg", ".jpe", ".png", ".gif", ".webp"}

RAW_EXTENSIONS = {
    ".raw",
    ".dng",
    ".cr2",
    ".cr3",
    ".nef",
    ".nrw",
    ".arw",
    ".srf",
    ".sr2",
    ".raf",
    ".rw2",
    ".orf",
    ".srw",
    ".pef",
    ".erf",
    ".3fr",
    ".fff",
    ".iiq",
    ".mos",
    ".kdc",
    ".dcr",
    ".x3f",
}

TIFF_EXTENSIONS = {".tif", ".tiff", ".tng"}

CATEGORY_FOLDERS = {
    "like": "喜欢",
    "reject": "不喜欢",
    "maybe": "待定",
}

PREFLIGHT_FOLDER = "初筛_需复查"
SESSION_COOKIE = "dingzhen_session"
ADMIN_EMAIL = "cy15190111657@163.com"
ADMIN_SALT = "dingzhen-admin-cy-2026"
ADMIN_HASH = "c13a6a411939b2a873bbef3e1bed005923f3e4eda495dc66291ffe36212bb142"
TRIAL_SECONDS = 7 * 24 * 60 * 60
SUBSCRIPTION_PLANS = {
    "eu_month": {"region": "eu", "label": "1 month", "price": "3.9 EUR", "days": 31},
    "eu_3m": {"region": "eu", "label": "3 months", "price": "9.9 EUR", "days": 93},
    "eu_6m": {"region": "eu", "label": "6 months", "price": "17.9 EUR", "days": 186},
    "eu_year": {"region": "eu", "label": "1 year", "price": "30.9 EUR", "days": 365},
    "usd_month": {"region": "usd", "label": "1 month", "price": "4.5 USD", "days": 31},
    "usd_3m": {"region": "usd", "label": "3 months", "price": "11.5 USD", "days": 93},
    "usd_6m": {"region": "usd", "label": "6 months", "price": "20.8 USD", "days": 186},
    "usd_year": {"region": "usd", "label": "1 year", "price": "35.9 USD", "days": 365},
    "cn_month": {"region": "cn", "label": "1 month", "price": "25 CNY", "days": 31},
    "cn_3m": {"region": "cn", "label": "3 months", "price": "55 CNY", "days": 93},
    "cn_6m": {"region": "cn", "label": "6 months", "price": "100 CNY", "days": 186},
    "cn_year": {"region": "cn", "label": "1 year", "price": "185 CNY", "days": 365},
}

CATEGORY_LABELS = {
    "like": {"zh": "喜欢", "en": "Keep"},
    "reject": {"zh": "不喜欢", "en": "Pass"},
    "maybe": {"zh": "待定", "en": "Maybe"},
}

SKIP_FOLDER_NAMES = set(CATEGORY_FOLDERS.values()) | {PREFLIGHT_FOLDER}
DISPLAY_SIZE = "2400"

def app_data_dir() -> Path:
    if sys.platform == "darwin":
        base = Path.home() / "Library" / "Application Support"
    elif sys.platform.startswith("win"):
        base = Path(os.getenv("APPDATA", Path.home() / "AppData" / "Roaming"))
    else:
        base = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
    target = base / "DingZhen"
    target.mkdir(parents=True, exist_ok=True)
    return target


APP_DATA_DIR = app_data_dir()
ACCOUNTS_FILE = APP_DATA_DIR / "dingzhen_accounts.json"
CLOUD_SESSION_FILE = APP_DATA_DIR / "dingzhen_cloud_session.json"
CLOUD_APP_VERSION = "0.2.0"
DEFAULT_CLOUD_URL = "https://dingzheniphoto.com"


@dataclass(frozen=True)
class PhotoItem:
    path: Path

    @property
    def name(self) -> str:
        return self.path.name


@dataclass(frozen=True)
class CullingAction:
    group_id: int
    mode: str
    category: str
    source: Path
    destination: Path
    index_before: int
    previous_decision: str | None


def is_photo(path: Path) -> bool:
    return path.is_file() and path.suffix.lower() in PHOTO_EXTENSIONS


def format_group_for(path: Path) -> str:
    suffix = path.suffix.lower()
    if suffix in {".jpg", ".jpeg", ".jpe"}:
        return "JPG"
    if suffix == ".png":
        return "PNG"
    if suffix == ".webp":
        return "WEBP"
    if suffix == ".gif":
        return "GIF"
    if suffix in {".heic", ".heif"}:
        return "HEIC"
    if suffix in TIFF_EXTENSIONS:
        return "TIFF"
    if suffix in RAW_EXTENSIONS:
        return "RAW"
    return suffix.replace(".", "").upper() or "OTHER"


def collect_photos(folder: Path) -> list[PhotoItem]:
    photos: list[PhotoItem] = []
    for path in folder.rglob("*"):
        if any(part in SKIP_FOLDER_NAMES for part in path.parts):
            continue
        if is_photo(path):
            photos.append(PhotoItem(path))
    return sorted(photos, key=lambda item: (format_group_for(item.path), str(item.path).lower()))


def display_fraction(value: object) -> str:
    if value is None:
        return ""
    numerator = getattr(value, "numerator", None)
    denominator = getattr(value, "denominator", None)
    if numerator is not None and denominator:
        if numerator > denominator:
            return f"{float(numerator) / float(denominator):.1f}".rstrip("0").rstrip(".")
        return f"{numerator}/{denominator}"
    if isinstance(value, tuple) and len(value) == 2 and value[1]:
        if value[0] > value[1]:
            return f"{float(value[0]) / float(value[1]):.1f}".rstrip("0").rstrip(".")
        return f"{value[0]}/{value[1]}"
    if isinstance(value, float):
        return f"{value:.1f}".rstrip("0").rstrip(".")
    return str(value)


def normalize_photo_info(raw: dict[str, object]) -> dict[str, str]:
    iso = raw.get("ISOSpeedRatings") or raw.get("PhotographicSensitivity")
    aperture = raw.get("FNumber") or raw.get("ApertureValue")
    shutter = raw.get("ExposureTime") or raw.get("ShutterSpeedValue")
    taken_at = raw.get("DateTimeOriginal") or raw.get("DateTimeDigitized") or raw.get("DateTime")

    aperture_text = display_fraction(aperture)
    if aperture_text and not aperture_text.lower().startswith("f"):
        aperture_text = f"f/{aperture_text}"

    shutter_text = display_fraction(shutter)
    if shutter_text and "/" not in shutter_text:
        try:
            seconds = float(shutter_text)
            shutter_text = f"{seconds:.4f}".rstrip("0").rstrip(".") + "s"
        except ValueError:
            pass

    return {
        "iso": str(iso) if iso else "",
        "aperture": aperture_text,
        "shutter": shutter_text,
        "takenAt": str(taken_at) if taken_at else "",
    }


def read_photo_info_with_pillow(path: Path) -> dict[str, str]:
    try:
        from PIL import ExifTags, Image
    except Exception:
        return {}

    try:
        with Image.open(path) as image:
            exif = image.getexif()
            if not exif:
                return {}
            raw = {ExifTags.TAGS.get(tag, str(tag)): value for tag, value in exif.items()}
            return normalize_photo_info(raw)
    except Exception:
        return {}


def read_photo_info_builtin(path: Path) -> dict[str, str]:
    try:
        data = path.read_bytes()
    except Exception:
        return {}

    if data.startswith(b"\xff\xd8"):
        offset = 2
        while offset + 4 <= len(data):
            if data[offset] != 0xFF:
                break
            marker = data[offset + 1]
            offset += 2
            if marker in {0xD8, 0xD9}:
                continue
            if offset + 2 > len(data):
                break
            segment_length = int.from_bytes(data[offset : offset + 2], "big")
            segment = data[offset + 2 : offset + segment_length]
            if marker == 0xE1 and segment.startswith(b"Exif\x00\x00"):
                return read_tiff_exif(segment[6:])
            offset += segment_length
        return {}

    if data.startswith((b"II*\x00", b"MM\x00*")):
        return read_tiff_exif(data)

    return {}


def read_tiff_exif(data: bytes) -> dict[str, str]:
    if len(data) < 8 or data[:2] not in {b"II", b"MM"}:
        return {}

    endian = "<" if data[:2] == b"II" else ">"
    type_sizes = {1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8}
    tag_names = {
        0x010F: "Make",
        0x0110: "Model",
        0x0132: "DateTime",
        0x829A: "ExposureTime",
        0x829D: "FNumber",
        0x8769: "ExifIFDPointer",
        0x8827: "ISOSpeedRatings",
        0x8833: "PhotographicSensitivity",
        0x9003: "DateTimeOriginal",
        0x9004: "DateTimeDigitized",
        0x9201: "ShutterSpeedValue",
        0x9202: "ApertureValue",
    }

    def unpack(fmt: str, start: int) -> int | tuple[int, ...]:
        return struct.unpack_from(endian + fmt, data, start)[0]

    def parse_value(value_type: int, count: int, value_offset: int) -> object:
        size = type_sizes.get(value_type, 1) * count
        raw = data[value_offset : value_offset + size] if size > 4 else data[value_offset : value_offset + 4]
        if size > 4:
            pointer = int.from_bytes(raw[:4], "little" if endian == "<" else "big")
            raw = data[pointer : pointer + size]
        try:
            if value_type == 2:
                return raw[:size].split(b"\x00", 1)[0].decode("utf-8", "replace")
            if value_type == 3:
                values = struct.unpack(endian + "H" * count, raw[: 2 * count])
                return values[0] if count == 1 else values
            if value_type == 4:
                values = struct.unpack(endian + "I" * count, raw[: 4 * count])
                return values[0] if count == 1 else values
            if value_type == 5:
                numerator, denominator = struct.unpack(endian + "II", raw[:8])
                return (numerator, denominator)
            if value_type == 9:
                values = struct.unpack(endian + "i" * count, raw[: 4 * count])
                return values[0] if count == 1 else values
            if value_type == 10:
                numerator, denominator = struct.unpack(endian + "ii", raw[:8])
                return (numerator, denominator)
        except Exception:
            return ""
        return ""

    def parse_ifd(offset: int, depth: int = 0) -> dict[str, object]:
        if depth > 2 or offset <= 0 or offset + 2 > len(data):
            return {}
        entries = unpack("H", offset)
        result: dict[str, object] = {}
        base = offset + 2
        for entry_index in range(int(entries)):
            entry = base + entry_index * 12
            if entry + 12 > len(data):
                break
            tag = int(unpack("H", entry))
            value_type = int(unpack("H", entry + 2))
            count = int(unpack("I", entry + 4))
            value_offset = entry + 8
            name = tag_names.get(tag)
            if not name:
                continue
            value = parse_value(value_type, count, value_offset)
            if name == "ExifIFDPointer":
                try:
                    result.update(parse_ifd(int(value), depth + 1))
                except Exception:
                    pass
            else:
                result[name] = value
        return result

    try:
        first_ifd = int(unpack("I", 4))
        return normalize_photo_info(parse_ifd(first_ifd))
    except Exception:
        return {}


def read_photo_info(path: Path) -> dict[str, str]:
    info = read_photo_info_with_pillow(path) or read_photo_info_builtin(path)
    if not any(info.values()):
        return {
            "iso": "",
            "aperture": "",
            "shutter": "",
            "takenAt": "",
            "message": "没有读到拍摄参数",
            "messageEn": "No camera settings found",
        }
    info["message"] = ""
    info["messageEn"] = ""
    return info


def unique_destination(destination: Path) -> Path:
    if not destination.exists():
        return destination

    stem = destination.stem
    suffix = destination.suffix
    parent = destination.parent
    counter = 2

    while True:
        candidate = parent / f"{stem} ({counter}){suffix}"
        if not candidate.exists():
            return candidate
        counter += 1


def choose_folder_with_dialog() -> Path | None:
    if sys.platform != "darwin":
        try:
            import tkinter as tk
            from tkinter import filedialog
        except Exception:
            return None

        root = tk.Tk()
        root.withdraw()
        root.attributes("-topmost", True)
        try:
            selected = filedialog.askdirectory(title="选择照片文件夹 / Choose photo folder")
        finally:
            root.destroy()
        return Path(selected) if selected else None

    script = 'POSIX path of (choose folder with prompt "选择照片文件夹 / Choose photo folder")'
    try:
        result = subprocess.run(
            ["osascript", "-e", script],
            check=True,
            text=True,
            capture_output=True,
        )
    except (subprocess.CalledProcessError, FileNotFoundError):
        return None

    selected = result.stdout.strip()
    return Path(selected) if selected else None


def open_browser(url: str) -> None:
    if sys.platform == "darwin":
        try:
            subprocess.run(["open", url], check=False)
            return
        except Exception:
            pass
    elif sys.platform.startswith("win"):
        commands = [
            ["cmd", "/c", "start", "", url],
            ["powershell", "-NoProfile", "-Command", "Start-Process", url],
        ]
        for command in commands:
            try:
                subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                return
            except Exception:
                pass

        try:
            os.startfile(url)  # type: ignore[attr-defined]
            return
        except Exception:
            pass

    webbrowser.open(url)


def write_windows_url_shortcut(url: str) -> None:
    if not sys.platform.startswith("win"):
        return

    try:
        Path("Open-DingZhen.url").write_text(f"[InternetShortcut]\nURL={url}\n", encoding="utf-8")
    except Exception:
        pass


def configure_console() -> None:
    if not sys.platform.startswith("win"):
        return

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


def print_startup_messages(url: str) -> None:
    if sys.platform.startswith("win"):
        print(f"DingZhen started: {url}", flush=True)
        print(f"If the browser does not open, copy this address into Chrome or Edge: {url}", flush=True)
        print("Keep this window open while using DingZhen.", flush=True)
        return

    print(f"{APP_TITLE} 已启动：{url}", flush=True)
    print(f"如果浏览器没有自动打开，请复制这个地址到浏览器：{url}", flush=True)
    print("关闭这个终端窗口即可退出。", flush=True)


def local_network_ip() -> str:
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
            sock.connect(("8.8.8.8", 80))
            return sock.getsockname()[0]
    except Exception:
        return "127.0.0.1"


def password_hash(password: str, salt: str | None = None) -> tuple[str, str]:
    salt = salt or secrets.token_hex(16)
    digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 180_000)
    return salt, digest.hex()


def constant_time_equal(left: str, right: str) -> bool:
    return secrets.compare_digest(left.encode("utf-8"), right.encode("utf-8"))


def send_verification_email(email: str, code: str) -> bool:
    host = os.getenv("DINGZHEN_SMTP_HOST", "")
    user = os.getenv("DINGZHEN_SMTP_USER", "")
    password = os.getenv("DINGZHEN_SMTP_PASSWORD", "")
    sender = os.getenv("DINGZHEN_SMTP_FROM", user)
    if not host or not user or not password or not sender:
        return False

    port = int(os.getenv("DINGZHEN_SMTP_PORT", "587"))
    use_ssl = os.getenv("DINGZHEN_SMTP_SSL", "") == "1" or port == 465
    message = EmailMessage()
    message["Subject"] = "定帧验证码 / DingZhen verification code"
    message["From"] = sender
    message["To"] = email
    message.set_content(f"你的定帧验证码是：{code}\n\nThis code expires in 10 minutes.")

    context = ssl.create_default_context()
    if use_ssl:
        with smtplib.SMTP_SSL(host, port, timeout=20, context=context) as smtp:
            smtp.login(user, password)
            smtp.send_message(message)
    else:
        with smtplib.SMTP(host, port, timeout=20) as smtp:
            smtp.starttls(context=context)
            smtp.login(user, password)
            smtp.send_message(message)
    return True


def cloud_base_url() -> str:
    return os.getenv("DINGZHEN_CLOUD_URL", DEFAULT_CLOUD_URL).strip().rstrip("/")


def cloud_required() -> bool:
    return os.getenv("DINGZHEN_REQUIRE_CLOUD", "") == "1"


def desktop_device_id() -> str:
    raw = f"{socket.gethostname()}|{APP_DATA_DIR}|{sys.platform}"
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]


def cloud_request(path: str, payload: dict | None = None, token: str = "") -> dict:
    base = cloud_base_url()
    if not base:
        return {"ok": False, "message": "没有配置云端地址", "messageEn": "Cloud URL is not configured"}
    data = json.dumps(payload or {}, ensure_ascii=False).encode("utf-8")
    request = urllib.request.Request(
        f"{base}{path}",
        data=data if payload is not None else None,
        headers={
            "Content-Type": "application/json; charset=utf-8",
            **({"Authorization": f"Bearer {token}"} if token else {}),
        },
        method="POST" if payload is not None else "GET",
    )
    try:
        with urllib.request.urlopen(request, timeout=12) as response:
            return json.loads(response.read().decode("utf-8"))
    except Exception as error:
        return {"ok": False, "message": f"云端连接失败：{error}", "messageEn": f"Cloud connection failed: {error}"}


def read_cloud_session_file() -> dict:
    try:
        data = json.loads(CLOUD_SESSION_FILE.read_text(encoding="utf-8"))
    except Exception:
        return {}
    return data if isinstance(data, dict) else {}


def write_cloud_session_file(data: dict) -> None:
    CLOUD_SESSION_FILE.parent.mkdir(parents=True, exist_ok=True)
    CLOUD_SESSION_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")


def cloud_saved_token() -> str:
    return str(read_cloud_session_file().get("token", ""))


def cloud_login_and_activate(email: str, password: str) -> dict:
    if not cloud_base_url():
        return {"ok": True, "enabled": False}
    login = cloud_request("/api/auth/login", {"email": email, "password": password})
    if not login.get("ok"):
        return login
    token = str(login.get("token", ""))
    user = login.get("user", {}) if isinstance(login.get("user"), dict) else {}
    activation = cloud_request(
        "/api/devices/activate",
        {
            "deviceId": desktop_device_id(),
            "platform": sys.platform,
            "appVersion": CLOUD_APP_VERSION,
        },
        token=token,
    )
    subscription = activation.get("subscription", {}) if isinstance(activation, dict) else {}
    if not subscription:
        subscription = user.get("subscription", {}) if isinstance(user, dict) else {}
    write_cloud_session_file(
        {
            "cloudUrl": cloud_base_url(),
            "token": token,
            "email": user.get("email", email),
            "role": user.get("role", "user"),
            "offlineGraceUntil": activation.get("offlineGraceUntil", 0) if isinstance(activation, dict) else 0,
            "subscription": subscription or {},
        }
    )
    return {
        "ok": True,
        "enabled": True,
        "token": token,
        "user": user,
        "subscription": subscription or {},
        "activation": activation,
    }


def cloud_refresh_session(force_activate: bool = True) -> dict:
    if not cloud_base_url():
        return {"ok": False, "enabled": False}
    data = read_cloud_session_file()
    token = str(data.get("token", ""))
    if not token:
        return {"ok": False, "enabled": True, "connected": False, "cloudUrl": cloud_base_url()}
    me = cloud_request("/api/me", token=token)
    if not me.get("ok"):
        return {
            **me,
            "enabled": True,
            "connected": False,
            "token": token,
            "cached": data,
        }
    user = me.get("user", {}) if isinstance(me.get("user"), dict) else {}
    activation = {}
    if force_activate:
        activation = cloud_request(
            "/api/devices/activate",
            {
                "deviceId": desktop_device_id(),
                "platform": sys.platform,
                "appVersion": CLOUD_APP_VERSION,
            },
            token=token,
        )
    subscription = activation.get("subscription", {}) if isinstance(activation, dict) else {}
    if not subscription:
        subscription = user.get("subscription", {}) if isinstance(user, dict) else {}
    write_cloud_session_file(
        {
            "cloudUrl": cloud_base_url(),
            "token": token,
            "email": user.get("email", data.get("email", "")),
            "role": user.get("role", data.get("role", "user")),
            "offlineGraceUntil": activation.get("offlineGraceUntil", data.get("offlineGraceUntil", 0)) if isinstance(activation, dict) else data.get("offlineGraceUntil", 0),
            "subscription": subscription or {},
        }
    )
    return {
        "ok": True,
        "enabled": True,
        "connected": True,
        "token": token,
        "user": user,
        "subscription": subscription or {},
        "activation": activation,
    }


def cloud_start_trial() -> dict:
    token = cloud_saved_token()
    if not token:
        return {"ok": False, "message": "请先登录", "messageEn": "Please log in first"}
    result = cloud_request("/api/billing/start-trial", {}, token=token)
    if result.get("ok"):
        cloud_refresh_session(force_activate=False)
    return result


def cloud_create_checkout(plan: str, provider: str) -> dict:
    token = cloud_saved_token()
    if not token:
        return {"ok": False, "message": "请先登录", "messageEn": "Please log in first"}
    return cloud_request("/api/billing/checkout", {"plan": plan, "provider": provider}, token=token)


def cloud_session_state() -> dict:
    if not cloud_base_url():
        return {"enabled": False}
    data = read_cloud_session_file()
    if not data:
        return {"enabled": True, "connected": False, "cloudUrl": cloud_base_url()}
    return {
        "enabled": True,
        "connected": bool(data.get("token")),
        "cloudUrl": data.get("cloudUrl", cloud_base_url()),
        "email": data.get("email", ""),
        "offlineGraceUntil": data.get("offlineGraceUntil", 0),
        "subscription": data.get("subscription", {}),
    }


def clear_cloud_session() -> None:
    if CLOUD_SESSION_FILE.exists():
        try:
            CLOUD_SESSION_FILE.unlink()
        except Exception:
            pass


class AuthManager:
    def __init__(self, account_path: Path) -> None:
        self.account_path = account_path
        self.accounts = self._load_accounts()
        self.cloud_profiles: dict[str, dict[str, object]] = {}
        self.pending: dict[str, dict[str, object]] = {}
        self.sessions: dict[str, str] = {}
        self.invites: dict[str, dict[str, object]] = {}
        self.lock = threading.RLock()
        self._ensure_admin()

    def _load_accounts(self) -> dict[str, dict[str, str]]:
        try:
            data = json.loads(self.account_path.read_text(encoding="utf-8"))
        except Exception:
            return {}
        return data if isinstance(data, dict) else {}

    def _save_accounts(self) -> None:
        self.account_path.parent.mkdir(parents=True, exist_ok=True)
        self.account_path.write_text(json.dumps(self.accounts, ensure_ascii=False, indent=2), encoding="utf-8")

    def _ensure_admin(self) -> None:
        with self.lock:
            current = self.accounts.get(ADMIN_EMAIL, {})
            if current.get("hash") == ADMIN_HASH and current.get("role") == "admin" and current.get("subscription") == "admin":
                return
            self.accounts[ADMIN_EMAIL] = {
                "salt": ADMIN_SALT,
                "hash": ADMIN_HASH,
                "role": "admin",
                "subscription": "admin",
                "createdAt": str(int(time.time())),
            }
            self._save_accounts()

    def start_register(self, email: str, password: str) -> dict:
        normalized_email = email.strip().lower()
        if cloud_base_url():
            return cloud_request("/api/auth/register-start", {"email": normalized_email, "password": password})
        if "@" not in normalized_email or "." not in normalized_email:
            return {"ok": False, "message": "邮箱格式不对", "messageEn": "Invalid email"}
        if len(password) < 6:
            return {"ok": False, "message": "密码至少 6 位", "messageEn": "Password must be at least 6 characters"}

        with self.lock:
            if normalized_email in self.accounts:
                return {"ok": False, "message": "这个邮箱已经注册过", "messageEn": "Email already registered"}
            salt, hashed = password_hash(password)
            code = f"{secrets.randbelow(900000) + 100000}"
            self.pending[normalized_email] = {
                "salt": salt,
                "hash": hashed,
                "code": code,
                "expires": time.time() + 600,
            }

        sent = False
        try:
            sent = send_verification_email(normalized_email, code)
        except Exception:
            sent = False

        if sent:
            return {"ok": True, "message": "验证码已发送到邮箱", "messageEn": "Verification code sent"}

        return {
            "ok": True,
            "message": f"未配置发信邮箱，测试验证码：{code}",
            "messageEn": f"Email sending is not configured. Test code: {code}",
            "debugCode": code,
        }

    def confirm_register(self, email: str, code: str) -> dict:
        normalized_email = email.strip().lower()
        if cloud_base_url():
            return cloud_request("/api/auth/register-confirm", {"email": normalized_email, "code": code.strip()})
        with self.lock:
            pending = self.pending.get(normalized_email)
            if not pending:
                return {"ok": False, "message": "请先获取验证码", "messageEn": "Request a code first"}
            if time.time() > float(pending.get("expires", 0)):
                self.pending.pop(normalized_email, None)
                return {"ok": False, "message": "验证码已过期", "messageEn": "Code expired"}
            if str(pending.get("code", "")) != code.strip():
                return {"ok": False, "message": "验证码不对", "messageEn": "Incorrect code"}
            self.accounts[normalized_email] = {
                "salt": str(pending["salt"]),
                "hash": str(pending["hash"]),
                "role": "user",
                "trialStartedAt": "",
                "trialEndsAt": "",
                "subscriptionExpiresAt": "",
                "subscriptionPlan": "",
                "createdAt": str(int(time.time())),
            }
            self.pending.pop(normalized_email, None)
            self._save_accounts()
        return {"ok": True, "message": "注册完成，可以登录", "messageEn": "Registered. You can log in now"}

    def login(self, email: str, password: str) -> dict:
        normalized_email = email.strip().lower()
        if cloud_base_url():
            cloud = cloud_login_and_activate(normalized_email, password)
            if not cloud.get("ok"):
                return cloud
            user = cloud.get("user", {}) if isinstance(cloud.get("user"), dict) else {}
            resolved_email = str(user.get("email", normalized_email))
            role = str(user.get("role", "user"))
            token = secrets.token_urlsafe(32)
            with self.lock:
                self.sessions[token] = resolved_email
                self.cloud_profiles[resolved_email] = {
                    "role": role,
                    "subscription": cloud.get("subscription", {}) or {},
                }
            return {
                "ok": True,
                "email": resolved_email,
                "role": role,
                "token": token,
                "cloud": cloud,
                "subscription": cloud.get("subscription", {}) or {},
            }
        with self.lock:
            account = self.accounts.get(normalized_email)
            if not account:
                return {"ok": False, "message": "账号或密码不对", "messageEn": "Wrong email or password"}
            _, hashed = password_hash(password, account.get("salt", ""))
            if not constant_time_equal(hashed, account.get("hash", "")):
                return {"ok": False, "message": "账号或密码不对", "messageEn": "Wrong email or password"}
            token = secrets.token_urlsafe(32)
            self.sessions[token] = normalized_email
            role = account.get("role", "user")
        cloud = {"ok": True, "enabled": False}
        if role != "admin":
            cloud = cloud_login_and_activate(normalized_email, password)
            if not cloud.get("ok") and cloud_required():
                with self.lock:
                    self.sessions.pop(token, None)
                return cloud
        return {"ok": True, "email": normalized_email, "role": role, "token": token, "cloud": cloud}

    def create_invite(self, owner_email: str) -> dict:
        with self.lock:
            account = self.accounts.get(owner_email, {})
            if account.get("role") != "admin":
                return {"ok": False, "message": "只有管理员可以邀请", "messageEn": "Only admin can invite"}
            token = secrets.token_urlsafe(18)
            self.invites[token] = {"owner": owner_email, "expires": time.time() + 86400}
        return {"ok": True, "token": token}

    def accept_invite(self, token: str) -> dict:
        with self.lock:
            invite = self.invites.get(token)
            if not invite or time.time() > float(invite.get("expires", 0)):
                return {"ok": False, "message": "邀请无效或已过期", "messageEn": "Invite is invalid or expired"}
            guest_email = f"guest-{token[:8]}@invite.local"
            session = secrets.token_urlsafe(32)
            self.sessions[session] = guest_email
        return {"ok": True, "email": guest_email, "role": "guest", "token": session}

    def email_for_token(self, token: str) -> str:
        with self.lock:
            return self.sessions.get(token, "")

    def logout(self, token: str) -> None:
        with self.lock:
            email = self.sessions.pop(token, "")
            if email:
                self.cloud_profiles.pop(email, None)

    def role_for_email(self, email: str) -> str:
        if email.startswith("guest-") and email.endswith("@invite.local"):
            return "guest"
        with self.lock:
            if email in self.cloud_profiles:
                return str(self.cloud_profiles[email].get("role", ""))
            return self.accounts.get(email, {}).get("role", "")

    def subscription_status(self, email: str) -> dict:
        now = int(time.time())
        role = self.role_for_email(email)
        if role in {"admin", "guest"}:
            return {
                "active": True,
                "kind": role,
                "message": "管理员账号" if role == "admin" else "访客观看",
                "messageEn": "Admin account" if role == "admin" else "Guest viewer",
            }

        with self.lock:
            cloud_profile = self.cloud_profiles.get(email)
            if cloud_profile:
                subscription = cloud_profile.get("subscription", {})
                if isinstance(subscription, dict) and subscription:
                    return dict(subscription)
            account = self.accounts.get(email, {})
            trial_started = int(account.get("trialStartedAt") or 0)
            trial_ends = int(account.get("trialEndsAt") or 0)
            subscription_expires = int(account.get("subscriptionExpiresAt") or 0)
            plan = account.get("subscriptionPlan", "")

        if subscription_expires > now:
            return {
                "active": True,
                "kind": "paid",
                "plan": plan,
                "expiresAt": subscription_expires,
                "daysLeft": max(0, int((subscription_expires - now) / 86400)),
            }
        if trial_ends > now:
            return {
                "active": True,
                "kind": "trial",
                "trialStartedAt": trial_started,
                "trialEndsAt": trial_ends,
                "daysLeft": max(0, int((trial_ends - now + 86399) / 86400)),
            }
        if not trial_started:
            return {"active": False, "kind": "not_started", "canStartTrial": True}
        return {"active": False, "kind": "expired", "canStartTrial": False}

    def start_trial(self, email: str) -> dict:
        role = self.role_for_email(email)
        if role in {"admin", "guest"}:
            return {"ok": True, "subscription": self.subscription_status(email)}
        if cloud_base_url():
            result = cloud_start_trial()
            if result.get("ok"):
                with self.lock:
                    profile = self.cloud_profiles.setdefault(email, {"role": role})
                    profile["subscription"] = result.get("subscription", {}) or {}
            return result
        now = int(time.time())
        with self.lock:
            account = self.accounts.get(email)
            if not account:
                return {"ok": False, "message": "账号不存在", "messageEn": "Account not found"}
            if account.get("trialStartedAt"):
                return {"ok": False, "message": "这个账号已经使用过试用", "messageEn": "Trial already used"}
            account["trialStartedAt"] = str(now)
            account["trialEndsAt"] = str(now + TRIAL_SECONDS)
            self._save_accounts()
        return {"ok": True, "subscription": self.subscription_status(email)}

    def payment_option(self, email: str, plan_id: str, provider: str) -> dict:
        if cloud_base_url():
            return cloud_create_checkout(plan_id, provider)
        plan = SUBSCRIPTION_PLANS.get(plan_id)
        if not plan:
            return {"ok": False, "message": "未知订阅方案", "messageEn": "Unknown plan"}
        provider_key = provider.upper()
        url = os.getenv(f"DINGZHEN_{provider_key}_{plan_id.upper()}_URL", "") or os.getenv(f"DINGZHEN_{provider_key}_PAYMENT_URL", "")
        if not url:
            return {
                "ok": False,
                "message": "支付链接还没有配置。后面接 PayPal/微信/支付宝后台后，这里会自动跳转。",
                "messageEn": "Payment link is not configured yet.",
                "plan": plan,
                "provider": provider,
            }
        return {"ok": True, "url": url, "plan": plan, "provider": provider}

    def restore_cloud_login(self) -> dict:
        if not cloud_base_url():
            return {"ok": False, "enabled": False}
        cloud = cloud_refresh_session(force_activate=True)
        if not cloud.get("ok"):
            cached = read_cloud_session_file()
            email = str(cached.get("email", ""))
            if not email or not cached.get("token"):
                return cloud
            role = str(cached.get("role", "user"))
            token = secrets.token_urlsafe(32)
            with self.lock:
                self.sessions[token] = email
                self.cloud_profiles[email] = {
                    "role": role,
                    "subscription": cached.get("subscription", {}) or {},
                }
            return {
                "ok": True,
                "email": email,
                "role": role,
                "token": token,
                "cloud": {
                    "enabled": True,
                    "connected": False,
                    "cloudUrl": cached.get("cloudUrl", cloud_base_url()),
                    "email": email,
                    "subscription": cached.get("subscription", {}) or {},
                },
                "subscription": cached.get("subscription", {}) or {},
            }
        user = cloud.get("user", {}) if isinstance(cloud.get("user"), dict) else {}
        email = str(user.get("email", ""))
        role = str(user.get("role", "user"))
        if not email:
            return {"ok": False, "message": "云端会话缺少账号信息", "messageEn": "Cloud session is missing account information"}
        token = secrets.token_urlsafe(32)
        with self.lock:
            self.sessions[token] = email
            self.cloud_profiles[email] = {
                "role": role,
                "subscription": cloud.get("subscription", {}) or {},
            }
        return {
            "ok": True,
            "email": email,
            "role": role,
            "token": token,
            "cloud": cloud,
            "subscription": cloud.get("subscription", {}) or {},
        }


AUTH = AuthManager(ACCOUNTS_FILE)


def safe_reason_name(reasons: list[str]) -> str:
    text = "_".join(reasons) or "需复查"
    for char in '<>:"/\\|?*':
        text = text.replace(char, "-")
    return text[:80]


def analyze_preflight_image(image_path: Path) -> list[str]:
    reasons: list[str] = []
    try:
        from PIL import Image, ImageStat
    except Exception:
        return reasons

    try:
        with Image.open(image_path) as image:
            image.thumbnail((900, 900))
            grayscale = image.convert("L")
            stat = ImageStat.Stat(grayscale)
            mean = float(stat.mean[0])
            histogram = grayscale.histogram()
            total = max(sum(histogram), 1)
            bright_ratio = sum(histogram[245:]) / total
            dark_ratio = sum(histogram[:12]) / total
            if bright_ratio > 0.28 or mean > 238:
                reasons.append("过曝")
            if dark_ratio > 0.38 or mean < 32:
                reasons.append("欠曝")
            if likely_closed_eyes(image):
                reasons.append("疑似闭眼")
    except Exception:
        return reasons

    return reasons


def likely_closed_eyes(image: object) -> bool:
    try:
        import cv2
        import numpy as np
    except Exception:
        return False

    try:
        rgb = image.convert("RGB")
        frame = cv2.cvtColor(np.array(rgb), cv2.COLOR_RGB2GRAY)
        face_cascade = cv2.CascadeClassifier(str(Path(cv2.data.haarcascades) / "haarcascade_frontalface_default.xml"))
        eye_cascade = cv2.CascadeClassifier(str(Path(cv2.data.haarcascades) / "haarcascade_eye.xml"))
        faces = face_cascade.detectMultiScale(frame, scaleFactor=1.1, minNeighbors=6, minSize=(90, 90))
        if len(faces) == 0:
            return False
        for (x, y, width, height) in faces[:3]:
            upper_face = frame[y : y + max(1, height // 2), x : x + width]
            eyes = eye_cascade.detectMultiScale(upper_face, scaleFactor=1.08, minNeighbors=7, minSize=(18, 18))
            if len(eyes) > 0:
                return False
        return True
    except Exception:
        return False


class PhotoCullerState:
    def __init__(self) -> None:
        self.source_dir: Path | None = None
        self.photos: list[PhotoItem] = []
        self.index = 0
        self.total_count = 0
        self.mode = "copy"
        self.decisions: dict[str, str] = {}
        self.preflight_results: dict[str, list[str]] = {}
        self.history: list[CullingAction] = []
        self.next_group_id = 1
        self.display_dir = Path(tempfile.mkdtemp(prefix="photo-culler-display-"))
        self.lock = threading.RLock()

    def close(self) -> None:
        if self.display_dir.exists():
            shutil.rmtree(self.display_dir, ignore_errors=True)

    def load_folder(self, folder: Path) -> dict:
        photos = collect_photos(folder)
        with self.lock:
            self.source_dir = folder
            self.photos = photos
            self.total_count = len(photos)
            self.index = 0
            self.decisions = self._existing_decisions(folder, photos)
            self.preflight_results = {}
            self.history = []
            self.next_group_id = 1
        return self.snapshot()

    def choose_folder(self) -> dict:
        folder = choose_folder_with_dialog()
        if folder is None:
            return self.snapshot(message="没有选择文件夹", message_en="No folder selected")
        return self.load_folder(folder)

    def snapshot(self, message: str | None = None, message_en: str | None = None) -> dict:
        with self.lock:
            photos = [
                {
                    "index": index,
                    "name": item.name,
                    "path": str(item.path),
                    "extension": item.path.suffix.lower().replace(".", "").upper() or "OTHER",
                    "formatGroup": format_group_for(item.path),
                    "preflightReasons": self.preflight_results.get(str(item.path), []),
                    "preflightLabel": " / ".join(self.preflight_results.get(str(item.path), [])),
                    "decision": self.decisions.get(str(item.path), ""),
                    "decisionLabel": CATEGORY_FOLDERS.get(self.decisions.get(str(item.path), ""), ""),
                    "decisionLabelEn": CATEGORY_LABELS.get(
                        self.decisions.get(str(item.path), ""), {}
                    ).get("en", ""),
                }
                for index, item in enumerate(self.photos)
            ]
            decision_counts = self._decision_counts()
            completed_count = sum(decision_counts.values())
            return {
                "sourceDir": str(self.source_dir) if self.source_dir else "",
                "photos": photos,
                "index": self.index,
                "count": len(self.photos),
                "totalCount": self.total_count,
                "completed": self.total_count > 0 and completed_count >= self.total_count,
                "decisionCounts": decision_counts,
                "mode": self.mode,
                "modeLabel": "移动" if self.mode == "move" else "复制",
                "modeLabelEn": "Move" if self.mode == "move" else "Copy",
                "canUndo": bool(self.history),
                "message": message or "",
                "messageEn": message_en or "",
            }

    def photo_info(self, index: int) -> dict[str, str]:
        with self.lock:
            if index < 0 or index >= len(self.photos):
                return {
                    "iso": "",
                    "aperture": "",
                    "shutter": "",
                    "takenAt": "",
                    "message": "没有这张照片",
                    "messageEn": "Photo not found",
                }
            source = self.photos[index].path
        info = read_photo_info(source)
        info["name"] = source.name
        info["formatGroup"] = format_group_for(source)
        return info

    def preflight_screen(self) -> dict:
        try:
            import PIL  # noqa: F401
        except Exception:
            return self.snapshot(
                message="初筛需要 Pillow。Windows 新包会自动安装；如果手动运行，请先安装 pillow。",
                message_en="Preflight needs Pillow. The Windows package installs it automatically.",
            )

        with self.lock:
            if not self.photos or self.source_dir is None:
                return self.snapshot(message="还没有载入照片", message_en="No photos loaded yet")
            source_dir = self.source_dir
            photos = list(self.photos)

        flagged = 0
        analyzed = 0
        results: dict[str, list[str]] = {}

        for index, photo in enumerate(photos):
            display_path, _ = self.image_path(index)
            if display_path is None or not display_path.exists():
                reasons = ["无法生成预览"]
            else:
                analyzed += 1
                reasons = analyze_preflight_image(display_path)
            if not reasons:
                continue
            flagged += 1
            results[str(photo.path)] = reasons

        with self.lock:
            self.preflight_results = results

        if flagged == 0:
            return self.snapshot(message=f"初筛完成：未发现明显问题图（分析 {analyzed} 张）", message_en=f"Preflight complete: no obvious issues found ({analyzed} analyzed)")
        return self.snapshot(message=f"初筛完成：{flagged} 张已标记在左侧虚拟分类", message_en=f"Preflight complete: {flagged} marked in virtual groups")

    def _existing_decisions(self, folder: Path, photos: list[PhotoItem]) -> dict[str, str]:
        decisions: dict[str, str] = {}
        for item in photos:
            for category, folder_name in CATEGORY_FOLDERS.items():
                if (folder / folder_name / item.name).exists():
                    decisions[str(item.path)] = category
                    break
        return decisions

    def _decision_counts(self) -> dict[str, int]:
        counts = {category: 0 for category in CATEGORY_FOLDERS}
        for category in self.decisions.values():
            if category in counts:
                counts[category] += 1
        for action in self.history:
            if action.mode == "move" and action.category in counts:
                counts[action.category] += 1
        return counts

    def set_index(self, index: int) -> dict:
        with self.lock:
            if self.photos:
                self.index = max(0, min(index, len(self.photos) - 1))
        return self.snapshot()

    def set_mode(self, mode: str) -> dict:
        if mode not in {"copy", "move"}:
            return self.snapshot(message="未知模式", message_en="Unknown mode")
        with self.lock:
            self.mode = mode
        label = "移动" if mode == "move" else "复制"
        label_en = "Move" if mode == "move" else "Copy"
        return self.snapshot(message=f"当前模式：{label}", message_en=f"Mode: {label_en}")

    def categorize(self, category: str, index: int | None = None) -> dict:
        return self.categorize_many(category, [index] if index is not None else None)

    def categorize_many(self, category: str, indexes: list[int | None] | None = None) -> dict:
        if category not in CATEGORY_FOLDERS:
            return self.snapshot(message="未知分类", message_en="Unknown category")

        with self.lock:
            if not self.photos or self.source_dir is None:
                return self.snapshot(message="还没有载入照片", message_en="No photos loaded yet")

            selected_indexes = sorted(
                {
                    max(0, min(int(index), len(self.photos) - 1))
                    for index in (indexes if indexes else [self.index])
                    if index is not None
                }
            )
            if not selected_indexes:
                selected_indexes = [self.index]

            selected_photos = [
                (index, self.photos[index], self.decisions.get(str(self.photos[index].path)))
                for index in selected_indexes
            ]
            folder_name = CATEGORY_FOLDERS[category]
            mode = self.mode
            source_dir = self.source_dir
            group_id = self.next_group_id
            self.next_group_id += 1

        destination_dir = source_dir / folder_name
        destination_dir.mkdir(parents=True, exist_ok=True)
        actions: list[CullingAction] = []
        try:
            for index_before, photo, previous_decision in selected_photos:
                destination = unique_destination(destination_dir / photo.path.name)
                if mode == "move":
                    shutil.move(str(photo.path), str(destination))
                else:
                    shutil.copy2(photo.path, destination)
                actions.append(
                    CullingAction(
                        group_id=group_id,
                        mode=mode,
                        category=category,
                        source=photo.path,
                        destination=destination,
                        index_before=index_before,
                        previous_decision=previous_decision,
                    )
                )
        except Exception as exc:
            for action in reversed(actions):
                try:
                    if action.mode == "move" and action.destination.exists():
                        restore_path = action.source if not action.source.exists() else unique_destination(action.source)
                        restore_path.parent.mkdir(parents=True, exist_ok=True)
                        shutil.move(str(action.destination), str(restore_path))
                    elif action.destination.exists():
                        action.destination.unlink()
                except Exception:
                    pass
            action_label = "移动" if mode == "move" else "复制"
            action_label_en = "Move" if mode == "move" else "Copy"
            return self.snapshot(
                message=f"{action_label}失败：{exc}",
                message_en=f"{action_label_en} failed: {exc}",
            )

        with self.lock:
            self.history.extend(actions)
            if mode == "move":
                for index_before in sorted(selected_indexes, reverse=True):
                    self.photos.pop(index_before)
                self.index = min(selected_indexes[0], max(len(self.photos) - 1, 0))
                message = f"已移动 {len(actions)} 张到{folder_name}"
                message_en = f"Moved {len(actions)} to {CATEGORY_LABELS[category]['en']}"
            else:
                for action in actions:
                    self.decisions[str(action.source)] = category
                self.index = min(selected_indexes[-1] + 1, len(self.photos) - 1)
                message = f"已复制 {len(actions)} 张到{folder_name}"
                message_en = f"Copied {len(actions)} to {CATEGORY_LABELS[category]['en']}"
        return self.snapshot(message=message, message_en=message_en)

    def undo(self) -> dict:
        with self.lock:
            if not self.history:
                return self.snapshot(message="没有可撤销的操作", message_en="Nothing to undo")
            action = self.history.pop()
            actions = [action]
            while self.history and self.history[-1].group_id == action.group_id:
                actions.append(self.history.pop())

        try:
            mode = actions[0].mode
            if mode == "move":
                restored: list[tuple[int, PhotoItem]] = []
                for item in sorted(actions, key=lambda current: current.index_before):
                    restore_path = item.source if not item.source.exists() else unique_destination(item.source)
                    restore_path.parent.mkdir(parents=True, exist_ok=True)
                    shutil.move(str(item.destination), str(restore_path))
                    restored.append((item.index_before, PhotoItem(restore_path)))
                with self.lock:
                    for index_before, restored_item in restored:
                        insert_at = min(index_before, len(self.photos))
                        self.photos.insert(insert_at, restored_item)
                    self.index = min(restored[0][0], max(len(self.photos) - 1, 0))
                message = f"已撤销移动：{len(restored)} 张"
                message_en = f"Move undone: {len(restored)}"
            else:
                for item in actions:
                    if item.destination.exists():
                        item.destination.unlink()
                with self.lock:
                    for item in actions:
                        if item.previous_decision:
                            self.decisions[str(item.source)] = item.previous_decision
                        else:
                            self.decisions.pop(str(item.source), None)
                    self.index = min(min(item.index_before for item in actions), max(len(self.photos) - 1, 0))
                message = f"已撤销复制：{len(actions)} 张"
                message_en = f"Copy undone: {len(actions)}"
        except Exception as exc:
            with self.lock:
                self.history.extend(reversed(actions))
            return self.snapshot(message=f"撤销失败：{exc}", message_en=f"Undo failed: {exc}")

        return self.snapshot(message=message, message_en=message_en)

    def image_path(self, index: int) -> tuple[Path | None, str]:
        with self.lock:
            if index < 0 or index >= len(self.photos):
                return None, "text/plain"
            source = self.photos[index].path

        if source.suffix.lower() in BROWSER_NATIVE_EXTENSIONS:
            mime = mimetypes.guess_type(source.name)[0] or "application/octet-stream"
            return source, mime

        converted = self._make_display_image(index, source)
        return (converted, "image/jpeg") if converted else (None, "text/plain")

    def _make_display_image(self, index: int, source: Path) -> Path | None:
        out_dir = self.display_dir / f"image-{index}"
        direct = out_dir / "display.jpg"
        try:
            if direct.exists() and direct.stat().st_mtime >= source.stat().st_mtime:
                return direct
        except OSError:
            pass

        if out_dir.exists():
            shutil.rmtree(out_dir)
        out_dir.mkdir(parents=True, exist_ok=True)

        if self._make_rawpy_jpeg(source, direct):
            return direct

        if self._make_pillow_jpeg(source, direct):
            return direct

        if self._make_sips_jpeg(source, direct):
            return direct

        quicklook = self._make_quicklook_png(source, out_dir)
        if quicklook and self._make_sips_jpeg(quicklook, direct):
            return direct

        return None

    def _make_rawpy_jpeg(self, source: Path, destination: Path) -> bool:
        if source.suffix.lower() not in RAW_EXTENSIONS:
            return False

        try:
            import rawpy
            from PIL import Image
        except Exception:
            return False

        try:
            with rawpy.imread(str(source)) as raw:
                try:
                    thumbnail = raw.extract_thumb()
                    if thumbnail.format == rawpy.ThumbFormat.JPEG:
                        temp_thumb = destination.with_name("raw-thumb.jpg")
                        temp_thumb.write_bytes(thumbnail.data)
                        return self._resize_jpeg(temp_thumb, destination)
                    if thumbnail.format == rawpy.ThumbFormat.BITMAP:
                        image = Image.fromarray(thumbnail.data)
                    else:
                        image = None
                except Exception:
                    image = None

                if image is None:
                    rgb = raw.postprocess(
                        output_bps=8,
                        use_camera_wb=True,
                        half_size=True,
                        no_auto_bright=False,
                    )
                    image = Image.fromarray(rgb)

                image.thumbnail((int(DISPLAY_SIZE), int(DISPLAY_SIZE)))
                if image.mode != "RGB":
                    image = image.convert("RGB")
                destination.parent.mkdir(parents=True, exist_ok=True)
                image.save(destination, "JPEG", quality=92)
        except Exception:
            return False

        return destination.exists() and destination.stat().st_size > 0

    def _resize_jpeg(self, source: Path, destination: Path) -> bool:
        try:
            from PIL import Image, ImageOps
        except Exception:
            return False

        try:
            with Image.open(source) as image:
                image = ImageOps.exif_transpose(image)
                image.thumbnail((int(DISPLAY_SIZE), int(DISPLAY_SIZE)))
                if image.mode != "RGB":
                    image = image.convert("RGB")
                destination.parent.mkdir(parents=True, exist_ok=True)
                image.save(destination, "JPEG", quality=92)
        except Exception:
            return False

        return destination.exists() and destination.stat().st_size > 0

    def _make_pillow_jpeg(self, source: Path, destination: Path) -> bool:
        try:
            from PIL import Image, ImageOps
        except Exception:
            return False

        try:
            with Image.open(source) as image:
                image = ImageOps.exif_transpose(image)
                image.thumbnail((int(DISPLAY_SIZE), int(DISPLAY_SIZE)))
                if image.mode not in {"RGB", "L"}:
                    image = image.convert("RGB")
                destination.parent.mkdir(parents=True, exist_ok=True)
                image.save(destination, "JPEG", quality=92)
        except Exception:
            return False

        return destination.exists() and destination.stat().st_size > 0

    def _make_sips_jpeg(self, source: Path, destination: Path) -> bool:
        try:
            subprocess.run(
                ["sips", "-Z", DISPLAY_SIZE, "-s", "format", "jpeg", str(source), "--out", str(destination)],
                check=True,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
        except Exception:
            return False

        return destination.exists() and destination.stat().st_size > 0

    def _make_quicklook_png(self, source: Path, out_dir: Path) -> Path | None:
        try:
            subprocess.run(
                ["qlmanage", "-t", "-s", DISPLAY_SIZE, "-o", str(out_dir), str(source)],
                check=True,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
        except Exception:
            return None

        candidates = sorted(out_dir.glob("*.png"), key=lambda path: path.stat().st_mtime, reverse=True)
        return candidates[0] if candidates else None


HTML_PAGE = r"""<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>定帧</title>
  <style>
    :root {
      --ink: #050506;
      --paper: #fffbea;
      --cream: #f7f2d8;
      --mint: #bff4e0;
      --lime: #c7ff1a;
      --pink: #ffd2ef;
      --blue: #61b8e7;
      --orange: #ff6a22;
      --line: #050506;
      --muted: #777061;
      --radius: 18px;
    }
    * { box-sizing: border-box; }
    html, body { height: 100%; }
    body {
      margin: 0;
      background: #e9eee9;
      color: var(--ink);
      font-family: "Arial Black", "Helvetica Neue", Helvetica, sans-serif;
      overflow: hidden;
    }
    .app {
      display: grid;
      grid-template-columns: 322px minmax(0, 1fr);
      height: 100vh;
      min-width: 840px;
      padding: 10px;
      gap: 10px;
      background:
        radial-gradient(circle at 18px 18px, rgba(5, 5, 6, 0.07) 1px, transparent 1px),
        #e9eee9;
      background-size: 18px 18px;
    }
    .sidebar {
      display: flex;
      flex-direction: column;
      min-height: 0;
      background: var(--ink);
      border: 3px solid var(--line);
      border-radius: 22px;
      overflow: hidden;
    }
    .brand {
      display: grid;
      grid-template-columns: 58px minmax(0, 1fr);
      gap: 12px;
      align-items: center;
      padding: 16px 14px 12px;
      background: var(--lime);
      border-bottom: 3px solid var(--line);
    }
    .brand-mark {
      display: grid;
      place-items: center;
      width: 54px;
      height: 54px;
      border: 3px solid var(--line);
      border-radius: 16px;
      background: var(--paper);
      box-shadow: 4px 4px 0 var(--line);
      font-size: 26px;
      font-weight: 1000;
      line-height: 1;
    }
    .brand-logo {
      width: 43px;
      height: 43px;
      display: block;
    }
    .brand-title {
      font-size: 34px;
      font-weight: 1000;
      letter-spacing: 0;
      line-height: 0.95;
    }
    .brand-sub {
      margin-top: 4px;
      font-size: 11px;
      font-weight: 900;
      letter-spacing: 0.08em;
    }
    .top {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 8px;
      padding: 12px;
      background: var(--ink);
      border-bottom: 3px solid var(--line);
    }
    button {
      appearance: none;
      border: 2px solid var(--line);
      border-radius: 999px;
      background: var(--paper);
      color: var(--ink);
      cursor: pointer;
      font-size: 13px;
      font-weight: 1000;
      min-height: 40px;
      padding: 0 12px;
      text-transform: uppercase;
      box-shadow: 3px 3px 0 var(--line);
      transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease;
    }
    button:hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 var(--line); }
    button:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--line); }
    button:disabled { cursor: default; opacity: 0.45; transform: none; box-shadow: 2px 2px 0 var(--line); }
    .primary { background: var(--lime); color: var(--ink); }
    .like { background: var(--mint); color: var(--ink); }
    .reject { background: var(--pink); color: var(--ink); }
    .maybe { background: var(--blue); color: var(--ink); }
    .mode-move { background: var(--orange); color: var(--ink); }
    .status {
      margin: 12px;
      padding: 12px;
      border: 2px solid var(--line);
      border-radius: 14px;
      background: var(--cream);
      color: var(--ink);
      font-size: 12px;
      font-weight: 800;
      line-height: 1.35;
      min-height: 58px;
      overflow: hidden;
      box-shadow: 3px 3px 0 var(--line);
    }
    .list {
      flex: 1;
      min-height: 0;
      overflow: auto;
      padding: 0 10px 12px;
    }
    .row {
      display: flex;
      align-items: center;
      gap: 7px;
      width: 100%;
      border: 2px solid var(--line);
      border-radius: 13px;
      background: #151515;
      color: var(--paper);
      font-size: 12px;
      font-weight: 900;
      line-height: 1.25;
      min-height: 38px;
      overflow: hidden;
      padding: 8px 9px;
      text-align: left;
      box-shadow: none;
      margin-bottom: 7px;
    }
    .row-name {
      flex: 1;
      min-width: 0;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .badge {
      flex: 0 0 auto;
      border-radius: 999px;
      border: 2px solid var(--line);
      background: var(--paper);
      color: var(--ink);
      font-size: 11px;
      font-weight: 1000;
      line-height: 1;
      padding: 4px 6px;
    }
    .badge.like { background: var(--mint); color: var(--ink); }
    .badge.reject { background: var(--pink); color: var(--ink); }
    .badge.maybe { background: var(--blue); color: var(--ink); }
    .row.active {
      background: var(--paper);
      color: var(--ink);
      font-weight: 1000;
      box-shadow: 4px 4px 0 var(--lime);
    }
    .row.multi-selected {
      background: var(--lime);
      color: var(--ink);
      box-shadow: 4px 4px 0 var(--orange);
    }
    .row.multi-selected.active {
      background: var(--paper);
      box-shadow: 4px 4px 0 var(--orange);
    }
    .group-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 8px;
      margin: 12px 2px 7px;
      padding: 7px 10px;
      border: 2px solid var(--line);
      border-radius: 999px;
      background: var(--blue);
      color: var(--ink);
      font-size: 11px;
      font-weight: 1000;
      box-shadow: 3px 3px 0 var(--line);
    }
    .group-header:first-child { margin-top: 0; }
    .group-count {
      opacity: 0.74;
      font-size: 10px;
    }
    .main {
      position: relative;
      display: grid;
      grid-template-rows: auto minmax(0, 1fr) auto;
      min-width: 0;
      min-height: 0;
      background: var(--ink);
      border: 3px solid var(--line);
      border-radius: 22px;
      overflow: hidden;
    }
    .meta {
      display: grid;
      grid-template-columns: minmax(0, 1fr) auto 40px;
      gap: 12px;
      align-items: center;
      padding: 13px 16px;
      border-bottom: 3px solid var(--line);
      background: var(--paper);
    }
    .name {
      min-width: 0;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
      font-size: 20px;
      font-weight: 1000;
    }
    .count {
      border: 2px solid var(--line);
      border-radius: 999px;
      background: var(--lime);
      color: var(--ink);
      font-size: 12px;
      font-weight: 1000;
      padding: 7px 11px;
      white-space: nowrap;
    }
    .info-button {
      width: 38px;
      min-height: 38px;
      padding: 0;
      border-radius: 999px;
      font-size: 16px;
      line-height: 1;
    }
    .info-panel {
      position: absolute;
      top: 76px;
      right: 16px;
      z-index: 12;
      width: min(330px, calc(100% - 32px));
      display: none;
      border: 3px solid var(--line);
      border-radius: 20px;
      background: var(--paper);
      color: var(--ink);
      box-shadow: 8px 8px 0 var(--line);
      overflow: hidden;
    }
    .info-panel.show { display: block; }
    .info-head {
      display: grid;
      grid-template-columns: minmax(0, 1fr) 34px;
      align-items: center;
      gap: 8px;
      padding: 12px;
      border-bottom: 2px solid var(--line);
      background: var(--lime);
    }
    .info-title {
      min-width: 0;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
      font-size: 13px;
      font-weight: 1000;
    }
    .info-close {
      width: 32px;
      min-height: 32px;
      padding: 0;
      box-shadow: none;
    }
    .info-grid {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 8px;
      padding: 12px;
    }
    .info-item {
      border: 2px solid var(--line);
      border-radius: 14px;
      background: #ffffff;
      padding: 10px;
    }
    .info-label {
      color: var(--muted);
      font-size: 10px;
      font-weight: 900;
      text-transform: uppercase;
    }
    .info-value {
      margin-top: 4px;
      min-height: 19px;
      font-size: 14px;
      font-weight: 1000;
      overflow-wrap: anywhere;
    }
    .info-message {
      padding: 0 12px 12px;
      color: var(--muted);
      font-size: 12px;
      font-weight: 800;
    }
    .viewer {
      position: relative;
      display: flex;
      align-items: center;
      justify-content: center;
      min-height: 0;
      overflow: hidden;
      background:
        linear-gradient(45deg, rgba(255, 251, 234, 0.04) 25%, transparent 25%),
        linear-gradient(-45deg, rgba(255, 251, 234, 0.04) 25%, transparent 25%),
        #050506;
      background-size: 24px 24px;
      cursor: default;
      user-select: none;
    }
    .viewer.zoomed {
      align-items: flex-start;
      justify-content: flex-start;
      overflow: auto;
      cursor: grab;
    }
    .viewer.dragging {
      cursor: grabbing;
    }
    .viewer img {
      display: none;
      width: 100%;
      height: 100%;
      object-fit: contain;
      image-orientation: from-image;
      -webkit-user-drag: none;
      user-select: none;
    }
    .viewer.zoomed img {
      width: auto;
      height: auto;
      max-width: none;
      max-height: none;
      object-fit: initial;
    }
    .empty {
      max-width: 560px;
      border: 3px solid var(--line);
      border-radius: 20px;
      background: var(--cream);
      color: var(--ink);
      font-size: 26px;
      font-weight: 1000;
      line-height: 1.35;
      padding: 28px;
      text-align: center;
      box-shadow: 8px 8px 0 var(--lime);
    }
    .footer {
      display: grid;
      grid-template-columns: minmax(0, 1fr);
      gap: 10px;
      padding: 12px;
      border-top: 3px solid var(--line);
      background: var(--paper);
    }
    .actions {
      display: grid;
      grid-template-columns: 0.9fr 1fr 1fr 1fr 1fr 0.9fr;
      gap: 10px;
    }
    .zoom-controls {
      display: grid;
      grid-template-columns: 44px minmax(140px, 1fr) 62px 44px 72px;
      gap: 8px;
      align-items: center;
    }
    .footer button { min-height: 48px; font-size: 15px; }
    input[type="range"] {
      accent-color: var(--orange);
      width: 100%;
    }
    .zoom-value {
      border: 2px solid var(--line);
      border-radius: 999px;
      background: var(--ink);
      color: var(--paper);
      font-size: 13px;
      font-weight: 1000;
      line-height: 34px;
      text-align: center;
      white-space: nowrap;
    }
    .motion-overlay {
      position: fixed;
      inset: 10px;
      z-index: 20;
      display: none;
      place-items: center;
      border: 3px solid var(--line);
      border-radius: 24px;
      background: rgba(5, 5, 6, 0.9);
      overflow: hidden;
    }
    .motion-overlay.show {
      display: grid;
      animation: overlayIn 180ms ease both;
    }
    .motion-card {
      position: relative;
      width: min(720px, 78vw);
      height: min(420px, 68vh);
      display: grid;
      place-items: center;
    }
    .motion-title {
      position: absolute;
      left: 50%;
      bottom: 18px;
      transform: translateX(-50%);
      border: 3px solid var(--line);
      border-radius: 999px;
      background: var(--lime);
      color: var(--ink);
      padding: 10px 18px;
      font-size: 17px;
      font-weight: 1000;
      box-shadow: 5px 5px 0 var(--line);
      white-space: nowrap;
    }
    .file-box {
      position: absolute;
      width: 210px;
      height: 130px;
      bottom: 92px;
      border: 4px solid var(--line);
      border-radius: 16px;
      background: var(--cream);
      box-shadow: 8px 8px 0 var(--line);
    }
    .file-box::before {
      content: "";
      position: absolute;
      left: 18px;
      top: -30px;
      width: 112px;
      height: 34px;
      border: 4px solid var(--line);
      border-bottom: 0;
      border-radius: 14px 14px 0 0;
      background: var(--blue);
      transform-origin: bottom left;
    }
    .motion-overlay.loading .file-box::before {
      animation: lidOpen 900ms cubic-bezier(.2,.8,.2,1) both;
    }
    .motion-photo {
      position: absolute;
      width: 86px;
      height: 112px;
      border: 3px solid var(--line);
      border-radius: 10px;
      background: var(--paper);
      box-shadow: 4px 4px 0 var(--line);
      transform: translateY(45px) scale(0.72);
      opacity: 0;
    }
    .motion-photo::after {
      content: "";
      position: absolute;
      inset: 10px;
      border-radius: 7px;
      background: linear-gradient(135deg, var(--blue), var(--pink));
    }
    .motion-overlay.loading .p1 { animation: spreadLeft 1000ms 260ms cubic-bezier(.2,.8,.2,1) both; }
    .motion-overlay.loading .p2 { animation: spreadNearLeft 1000ms 320ms cubic-bezier(.2,.8,.2,1) both; }
    .motion-overlay.loading .p3 { animation: firstOpen 1150ms 420ms cubic-bezier(.2,.8,.2,1) both; }
    .motion-overlay.loading .p4 { animation: spreadNearRight 1000ms 340ms cubic-bezier(.2,.8,.2,1) both; }
    .motion-overlay.loading .p5 { animation: spreadRight 1000ms 280ms cubic-bezier(.2,.8,.2,1) both; }
    .finish-boxes {
      position: absolute;
      inset: 44px 26px 92px;
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      align-items: end;
      gap: 28px;
    }
    .finish-lane {
      position: relative;
      height: 230px;
      display: grid;
      place-items: end center;
    }
    .finish-label {
      position: absolute;
      top: 0;
      border: 3px solid var(--line);
      border-radius: 999px;
      background: var(--paper);
      padding: 6px 10px;
      font-size: 12px;
      font-weight: 1000;
      box-shadow: 3px 3px 0 var(--line);
    }
    .finish-mini {
      position: absolute;
      top: 62px;
      width: 54px;
      height: 70px;
      border: 3px solid var(--line);
      border-radius: 8px;
      background: var(--paper);
      box-shadow: 3px 3px 0 var(--line);
      animation: photoToBox 1200ms cubic-bezier(.2,.8,.2,1) both;
    }
    .finish-mini:nth-of-type(3) { animation-delay: 100ms; }
    .finish-mini:nth-of-type(4) { animation-delay: 180ms; }
    .finish-mini:nth-of-type(5) { animation-delay: 260ms; }
    .finish-lane .file-box {
      position: relative;
      width: 150px;
      height: 92px;
      bottom: 0;
      transform: scale(0.9);
    }
    .finish-lane.like .file-box { background: var(--mint); }
    .finish-lane.reject .file-box { background: var(--pink); }
    .finish-lane.maybe .file-box { background: var(--blue); }
    .motion-overlay.done .file-box::before {
      animation: lidClose 1000ms 740ms cubic-bezier(.2,.8,.2,1) both;
    }
    @keyframes overlayIn { from { opacity: 0; } to { opacity: 1; } }
    @keyframes lidOpen {
      0% { transform: rotate(0deg) translateY(0); }
      100% { transform: rotate(-42deg) translate(-6px, -8px); }
    }
    @keyframes lidClose {
      0% { transform: rotate(-42deg) translate(-6px, -8px); }
      100% { transform: rotate(0deg) translateY(0); }
    }
    @keyframes spreadLeft {
      0% { opacity: 0; transform: translateY(46px) scale(0.72) rotate(0); }
      100% { opacity: 1; transform: translate(-192px, -80px) scale(1) rotate(-14deg); }
    }
    @keyframes spreadNearLeft {
      0% { opacity: 0; transform: translateY(46px) scale(0.72) rotate(0); }
      100% { opacity: 1; transform: translate(-94px, -118px) scale(1) rotate(-7deg); }
    }
    @keyframes firstOpen {
      0% { opacity: 0; transform: translateY(46px) scale(0.72); }
      58% { opacity: 1; transform: translate(0, -140px) scale(1.12); }
      100% { opacity: 1; transform: translate(0, -156px) scale(1.42); box-shadow: 8px 8px 0 var(--lime); }
    }
    @keyframes spreadNearRight {
      0% { opacity: 0; transform: translateY(46px) scale(0.72) rotate(0); }
      100% { opacity: 1; transform: translate(98px, -118px) scale(1) rotate(8deg); }
    }
    @keyframes spreadRight {
      0% { opacity: 0; transform: translateY(46px) scale(0.72) rotate(0); }
      100% { opacity: 1; transform: translate(196px, -80px) scale(1) rotate(14deg); }
    }
    @keyframes photoToBox {
      0% { opacity: 0; transform: translateY(-80px) rotate(-8deg); }
      35% { opacity: 1; transform: translateY(-42px) rotate(5deg); }
      100% { opacity: 0; transform: translateY(90px) scale(0.55) rotate(0); }
    }
    @media (max-width: 900px) {
      .app { grid-template-columns: 220px minmax(0, 1fr); min-width: 0; }
      .actions { grid-template-columns: 1fr 1fr; }
      .zoom-controls { grid-template-columns: 44px minmax(120px, 1fr) 58px 44px 64px; }
    }

    /* Minimal UI variant: soft white mini-program style. */
    body {
      background:
        radial-gradient(circle at 20% 0%, rgba(255, 255, 255, 0.92), transparent 34%),
        linear-gradient(145deg, #f7f8fa 0%, #e6e9ef 58%, #d7dce5 100%);
      color: #111318;
      font-family: "Avenir Next", "Helvetica Neue", Helvetica, Arial, sans-serif;
    }
    .app {
      grid-template-columns: 318px minmax(0, 1fr);
      gap: 14px;
      padding: 16px;
      background: transparent;
    }
    .sidebar,
    .main {
      border: 1px solid rgba(17, 19, 24, 0.06);
      border-radius: 30px;
      background: rgba(255, 255, 255, 0.86);
      box-shadow: 0 28px 70px rgba(39, 47, 62, 0.16);
      backdrop-filter: blur(18px);
    }
    .brand {
      grid-template-columns: 56px minmax(0, 1fr);
      padding: 18px 18px 14px;
      background: #ffffff;
      border-bottom: 1px solid #edf0f4;
    }
    .brand-mark {
      width: 52px;
      height: 52px;
      border: 1px solid #e2e5ea;
      border-radius: 18px;
      background: #ffffff;
      box-shadow: inset 0 0 0 1px rgba(17, 19, 24, 0.03);
    }
    .brand-logo {
      width: 40px;
      height: 40px;
      filter: grayscale(1);
      opacity: 0.9;
    }
    .brand-title {
      font-size: 31px;
      font-weight: 850;
      line-height: 1;
      letter-spacing: 0;
    }
    .brand-sub {
      margin-top: 5px;
      color: #969ca7;
      font-size: 10px;
      font-weight: 700;
      letter-spacing: 0.18em;
    }
    .top {
      gap: 10px;
      padding: 14px 18px 12px;
      background: #ffffff;
      border-bottom: 0;
    }
    button {
      border: 1px solid #e7e9ee;
      border-radius: 18px;
      background: #f8f9fb;
      color: #15171c;
      box-shadow: none;
      font-size: 13px;
      font-weight: 750;
      text-transform: none;
      transition: background 140ms ease, transform 140ms ease, border-color 140ms ease;
    }
    button:hover {
      transform: translateY(-1px);
      box-shadow: none;
      background: #ffffff;
      border-color: #d9dde5;
    }
    button:active {
      transform: translateY(0);
      box-shadow: none;
    }
    button:disabled {
      box-shadow: none;
      opacity: 0.38;
    }
    .primary {
      background: #111318;
      border-color: #111318;
      color: #ffffff;
    }
    .mode-move {
      background: #111318;
      border-color: #111318;
      color: #ffffff;
    }
    .like { background: #f4faf6; color: #111318; }
    .reject { background: #fbf5f6; color: #111318; }
    .maybe { background: #f4f7fb; color: #111318; }
    .status {
      margin: 6px 18px 14px;
      min-height: 56px;
      border: 0;
      border-radius: 20px;
      background: #f7f8fa;
      color: #646a75;
      box-shadow: none;
      font-size: 12px;
      font-weight: 650;
    }
    .list {
      padding: 0 16px 16px;
    }
    .row {
      min-height: 42px;
      margin-bottom: 8px;
      border: 0;
      border-radius: 16px;
      background: #f7f8fa;
      color: #20232a;
      box-shadow: none;
      font-size: 12px;
      font-weight: 700;
    }
    .row.active,
    .row.multi-selected,
    .row.multi-selected.active {
      background: #111318;
      color: #ffffff;
      box-shadow: none;
    }
    .badge {
      border: 0;
      background: #ffffff;
      color: #111318;
      font-size: 10px;
      font-weight: 750;
    }
    .badge.like { background: #eaf5ef; color: #111318; }
    .badge.reject { background: #f5e9ec; color: #111318; }
    .badge.maybe { background: #e9eff8; color: #111318; }
    .group-header {
      border: 0;
      background: #eef2f7;
      color: #747b86;
      box-shadow: none;
      font-weight: 800;
    }
    .meta {
      min-height: 62px;
      padding: 14px 18px;
      border-bottom: 1px solid #edf0f4;
      background: #ffffff;
    }
    .name {
      font-size: 18px;
      font-weight: 800;
    }
    .count {
      border: 0;
      background: #f3f5f8;
      color: #4c535e;
      font-size: 12px;
      font-weight: 750;
    }
    .info-button {
      border: 0;
      background: #111318;
      color: #ffffff;
      font-weight: 850;
    }
    .info-panel {
      border: 1px solid #e7e9ee;
      border-radius: 22px;
      background: #ffffff;
      box-shadow: 0 22px 60px rgba(39, 47, 62, 0.18);
    }
    .info-head {
      border-bottom: 1px solid #edf0f4;
      background: #ffffff;
    }
    .info-close {
      border: 0;
      background: #f4f5f7;
    }
    .info-item {
      border: 0;
      background: #f7f8fa;
    }
    .info-label {
      color: #969ca7;
    }
    .viewer {
      background: #fbfbfc;
    }
    .viewer::before {
      content: "";
      position: absolute;
      inset: 22px;
      border: 1px solid rgba(17, 19, 24, 0.04);
      border-radius: 26px;
      pointer-events: none;
    }
    .empty {
      border: 0;
      border-radius: 28px;
      background: #ffffff;
      color: #111318;
      box-shadow: 0 18px 46px rgba(39, 47, 62, 0.1);
      font-size: 24px;
      font-weight: 850;
    }
    .footer {
      padding: 14px;
      border-top: 1px solid #edf0f4;
      background: #ffffff;
    }
    .actions {
      gap: 9px;
    }
    .footer button {
      min-height: 46px;
      font-size: 14px;
    }
    .zoom-controls {
      grid-template-columns: 44px minmax(140px, 1fr) 62px 44px 72px;
      gap: 9px;
    }
    input[type="range"] {
      accent-color: #111318;
    }
    .zoom-value {
      border: 0;
      border-radius: 16px;
      background: #f3f5f8;
      color: #4c535e;
      font-weight: 750;
      line-height: 36px;
    }
    .motion-overlay {
      inset: 16px;
      border: 1px solid rgba(17, 19, 24, 0.08);
      border-radius: 32px;
      background: rgba(247, 248, 250, 0.88);
      backdrop-filter: blur(18px);
    }
    .motion-title,
    .finish-label {
      border: 0;
      background: #ffffff;
      color: #111318;
      box-shadow: 0 14px 38px rgba(39, 47, 62, 0.14);
    }
    .file-box,
    .finish-lane .file-box,
    .motion-photo,
    .finish-mini {
      border: 1px solid #dfe3ea;
      box-shadow: 0 14px 38px rgba(39, 47, 62, 0.12);
      background: #ffffff;
    }
    .file-box::before {
      border-color: #dfe3ea;
      background: #f2f4f7;
    }
    .auth-gate {
      position: fixed;
      inset: 0;
      z-index: 50;
      display: grid;
      place-items: center;
      background:
        radial-gradient(circle at 20% 12%, rgba(255, 255, 255, 0.95), transparent 36%),
        linear-gradient(145deg, #f7f8fa 0%, #e2e6ee 100%);
      padding: 22px;
    }
    .auth-gate.hidden { display: none; }
    .auth-card {
      width: min(420px, 100%);
      border: 1px solid #e7e9ee;
      border-radius: 28px;
      background: #ffffff;
      box-shadow: 0 28px 70px rgba(39, 47, 62, 0.16);
      padding: 22px;
    }
    .auth-title {
      font-size: 34px;
      font-weight: 850;
      line-height: 1;
    }
    .auth-sub {
      margin-top: 6px;
      color: #8a909b;
      font-size: 12px;
      font-weight: 700;
      letter-spacing: 0.12em;
    }
    .auth-tabs {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 8px;
      margin: 22px 0 14px;
    }
    .auth-tabs button.active {
      background: #111318;
      color: #ffffff;
    }
    .auth-form {
      display: grid;
      gap: 10px;
    }
    .auth-form input {
      width: 100%;
      min-height: 46px;
      border: 1px solid #e4e7ed;
      border-radius: 16px;
      background: #f8f9fb;
      color: #111318;
      font: inherit;
      font-size: 14px;
      padding: 0 13px;
      outline: none;
    }
    .auth-form input:focus {
      border-color: #111318;
      background: #ffffff;
    }
    .auth-message {
      min-height: 20px;
      color: #69707b;
      font-size: 12px;
      font-weight: 700;
      line-height: 1.35;
    }
    .subscription-gate {
      position: fixed;
      inset: 0;
      z-index: 45;
      display: none;
      align-items: center;
      justify-content: center;
      background:
        radial-gradient(circle at 18% 8%, rgba(255, 255, 255, 0.96), transparent 35%),
        linear-gradient(145deg, #f7f8fa 0%, #e2e6ee 100%);
      padding: 22px;
      overflow: auto;
    }
    .subscription-gate.show { display: flex; }
    .subscription-card {
      width: min(860px, 100%);
      border: 1px solid #e7e9ee;
      border-radius: 30px;
      background: #ffffff;
      box-shadow: 0 28px 70px rgba(39, 47, 62, 0.16);
      padding: 22px;
    }
    .subscription-head {
      display: grid;
      grid-template-columns: minmax(0, 1fr) auto;
      gap: 12px;
      align-items: start;
      margin-bottom: 16px;
    }
    .subscription-title {
      font-size: 28px;
      font-weight: 850;
      line-height: 1.08;
    }
    .subscription-note {
      margin-top: 7px;
      color: #737a85;
      font-size: 13px;
      font-weight: 650;
      line-height: 1.45;
    }
    .region-tabs {
      display: grid;
      grid-template-columns: 1fr 1fr 1fr;
      gap: 8px;
      min-width: 250px;
    }
    .region-tabs button.active {
      background: #111318;
      color: #ffffff;
    }
    .trial-row {
      display: grid;
      grid-template-columns: minmax(0, 1fr) auto;
      gap: 12px;
      align-items: center;
      border-radius: 20px;
      background: #f7f8fa;
      padding: 14px;
      margin-bottom: 14px;
    }
    .plans {
      display: grid;
      grid-template-columns: repeat(4, minmax(0, 1fr));
      gap: 10px;
    }
    .plan-card {
      display: grid;
      gap: 10px;
      align-content: start;
      border: 1px solid #e7e9ee;
      border-radius: 20px;
      background: #fbfcfd;
      padding: 14px;
      min-height: 166px;
    }
    .plan-name {
      color: #6c737e;
      font-size: 12px;
      font-weight: 750;
    }
    .plan-price {
      font-size: 24px;
      font-weight: 850;
      letter-spacing: 0;
    }
    .plan-payments {
      display: grid;
      gap: 7px;
      margin-top: auto;
    }
    .payment-row {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 7px;
    }
    .subscription-message {
      min-height: 20px;
      margin-top: 12px;
      color: #69707b;
      font-size: 12px;
      font-weight: 700;
    }
    .profile-panel {
      position: fixed;
      inset: 0;
      z-index: 44;
      display: none;
      place-items: center;
      background: rgba(247, 248, 250, 0.72);
      backdrop-filter: blur(12px);
      padding: 22px;
    }
    .profile-panel.show { display: grid; }
    .profile-card {
      width: min(460px, 100%);
      border: 1px solid #e7e9ee;
      border-radius: 28px;
      background: #ffffff;
      box-shadow: 0 28px 70px rgba(39, 47, 62, 0.16);
      padding: 20px;
    }
    .profile-head {
      display: grid;
      grid-template-columns: minmax(0, 1fr) 40px;
      gap: 12px;
      align-items: center;
      margin-bottom: 14px;
    }
    .profile-title {
      font-size: 24px;
      font-weight: 850;
    }
    .profile-close {
      min-height: 38px;
      padding: 0;
      border-radius: 999px;
    }
    .profile-grid {
      display: grid;
      gap: 8px;
    }
    .profile-row {
      display: grid;
      grid-template-columns: 112px minmax(0, 1fr);
      gap: 12px;
      align-items: center;
      border-radius: 16px;
      background: #f7f8fa;
      padding: 12px;
    }
    .profile-label {
      color: #8a909b;
      font-size: 12px;
      font-weight: 750;
    }
    .profile-value {
      min-width: 0;
      overflow-wrap: anywhere;
      font-size: 14px;
      font-weight: 800;
    }
    .profile-actions {
      display: grid;
      grid-template-columns: 1fr;
      gap: 8px;
      margin-top: 14px;
    }
    @media (max-width: 900px) {
      .subscription-head,
      .trial-row {
        grid-template-columns: 1fr;
      }
      .plans {
        grid-template-columns: 1fr 1fr;
      }
    }
  </style>
</head>
<body>
  <div class="auth-gate" id="authGate">
    <div class="auth-card">
      <div class="auth-title">定帧</div>
      <div class="auth-sub">FRAME LOCK ACCOUNT</div>
      <div class="auth-tabs">
        <button id="loginTab" class="active" type="button">登录</button>
        <button id="registerTab" type="button">注册</button>
      </div>
      <div class="auth-form">
        <input id="authEmail" type="email" placeholder="邮箱">
        <input id="authPassword" type="password" placeholder="密码">
        <input id="authCode" type="text" inputmode="numeric" placeholder="验证码" style="display:none">
        <button class="primary" id="authMain" type="button">登录</button>
        <button id="sendCode" type="button" style="display:none">发送验证码</button>
        <div class="auth-message" id="authMessage"></div>
      </div>
    </div>
  </div>
  <div class="subscription-gate" id="subscriptionGate">
    <div class="subscription-card">
      <div class="subscription-head">
        <div>
          <div class="subscription-title">选择订阅服务</div>
          <div class="subscription-note" id="subscriptionNote">每个账号可以免费试用 1 星期。试用结束后需要订阅继续使用。</div>
        </div>
        <div style="display:grid; gap:8px;">
          <button id="subscriptionClose" type="button">关闭</button>
          <div class="region-tabs">
            <button id="currencyEur" class="active" type="button">EUR</button>
            <button id="currencyUsd" type="button">USD</button>
            <button id="currencyCny" type="button">RMB</button>
          </div>
        </div>
      </div>
      <div class="trial-row">
        <div>
          <strong>7 天免费试用</strong>
          <div class="subscription-note">不需要付款，试用结束后再选择订阅。</div>
        </div>
        <button class="primary" id="startTrial" type="button">开始免费试用 1 星期</button>
      </div>
      <div class="plans" id="plans"></div>
      <div class="subscription-message" id="subscriptionMessage"></div>
    </div>
  </div>
  <div class="app">
    <aside class="sidebar">
      <div class="brand">
        <div class="brand-mark" aria-label="定帧 logo">
          <svg class="brand-logo" viewBox="0 0 64 64" role="img" aria-hidden="true">
            <rect x="2" y="2" width="60" height="60" rx="12" fill="#fffbea"/>
            <path d="M8 21V8h13M43 8h13v13M56 43v13H43M21 56H8V43" fill="none" stroke="#050506" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
            <g stroke="#050506" stroke-width="2.4" stroke-linejoin="round">
              <rect x="11" y="28" width="10" height="12" rx="2" fill="#bff4e0"/>
              <rect x="20" y="26" width="10" height="16" rx="2" fill="#ffd2ef"/>
              <rect x="34" y="26" width="10" height="16" rx="2" fill="#61b8e7"/>
              <rect x="43" y="28" width="10" height="12" rx="2" fill="#f7f2d8"/>
            </g>
            <rect x="26" y="19" width="18" height="26" rx="3" fill="#c7ff1a" stroke="#050506" stroke-width="3"/>
            <path d="M30 37l4-5 3 4 3-7 3 8" fill="none" stroke="#050506" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
            <circle cx="33" cy="27" r="2.2" fill="#050506"/>
          </svg>
        </div>
        <div>
          <div class="brand-title">定帧</div>
          <div class="brand-sub">FRAME LOCK</div>
        </div>
      </div>
      <div class="top">
        <button class="primary" id="chooseFolder">选择文件夹</button>
        <button id="preflight">初步筛选</button>
        <button id="inviteButton">联机邀请</button>
        <button id="profileButton">个人</button>
        <button id="subscribeButton">订阅</button>
        <button id="reload">刷新</button>
        <button id="modeButton">模式：复制</button>
        <button id="langButton">EN</button>
      </div>
      <div class="status" id="status">先选择照片文件夹。</div>
      <div class="list" id="list"></div>
    </aside>
    <main class="main">
      <div class="meta">
        <div class="name" id="name">定帧</div>
        <div class="count" id="count"></div>
        <button class="info-button" id="infoButton" title="照片信息">i</button>
      </div>
      <div class="info-panel" id="infoPanel"></div>
      <div class="viewer" id="viewer">
        <img id="photo" alt="">
        <div class="empty" id="empty">点左上角“选择文件夹”开始</div>
      </div>
      <div class="footer">
        <div class="actions">
          <button id="prev">上一张</button>
          <button class="like" id="like">喜欢 L</button>
          <button class="reject" id="reject">不喜欢 D</button>
          <button class="maybe" id="maybe">待定 M</button>
          <button id="next">跳过</button>
          <button id="undo">撤销 Z</button>
        </div>
        <div class="zoom-controls">
          <button id="zoomOut">-</button>
          <input id="zoomRange" type="range" min="10" max="400" step="1" value="100">
          <div class="zoom-value" id="zoomValue">适配</div>
          <button id="zoomIn">+</button>
          <button id="fitZoom">适配</button>
        </div>
      </div>
    </main>
  </div>
  <div class="motion-overlay" id="motionOverlay" aria-hidden="true">
    <div class="motion-card" id="motionCard"></div>
  </div>
  <div class="profile-panel" id="profilePanel">
    <div class="profile-card">
      <div class="profile-head">
        <div class="profile-title">个人</div>
        <button class="profile-close" id="profileClose" type="button">×</button>
      </div>
      <div class="profile-grid" id="profileGrid"></div>
      <div class="profile-actions">
        <button class="primary" id="profileSubscribe" type="button">查看订阅</button>
        <button id="switchAccount" type="button">切换账号</button>
        <button id="logoutButton" type="button">退出登录</button>
      </div>
    </div>
  </div>
  <script>
    const copy = {
      zh: {
        chooseFolder: "选择文件夹",
        preflight: "初步筛选",
        invite: "联机邀请",
        inviteCopied: "邀请链接已复制，可以发给同一 Wi‑Fi 下的人",
        inviteFailed: "邀请失败",
        profile: "个人",
        subscribe: "订阅",
        switchAccount: "切换账号",
        logout: "退出登录",
        profileTitle: "个人",
        email: "邮箱",
        role: "身份",
        membership: "会员状态",
        trialLeft: "试用剩余",
        expiresAt: "到期时间",
        adminRole: "管理员",
        guestRole: "访客",
        userRole: "普通账号",
        trialStatus: "试用中",
        paidStatus: "订阅会员",
        expiredStatus: "试用已结束",
        notStartedStatus: "未开始试用",
        adminStatus: "管理员账号",
        guestStatus: "访客观看",
        cloud: "云端",
        cloudDisabled: "未配置",
        cloudConnected: "已连接",
        cloudPending: "等待连接",
        reload: "刷新",
        langButton: "EN",
        mode: "模式",
        copyMode: "复制",
        moveMode: "移动",
        empty: "点左上角“选择文件夹”开始",
        noFolder: "先选择照片文件夹。",
        choosing: "正在选择文件夹...",
        appName: "定帧",
        previous: "上一张",
        like: "喜欢 L",
        reject: "不喜欢 D",
        maybe: "待定 M",
        next: "跳过",
        undo: "撤销 Z",
        fit: "适配",
        imageError: "这张照片浏览器无法直接显示，但原文件仍然可以分类",
        loadingTitle: "打开选片盒",
        completeTitle: "选片完成",
        selectedCount: "已选",
        infoTitle: "照片参数",
        infoLoading: "正在读取参数...",
        infoUnavailable: "未读到",
        infoIso: "ISO",
        infoAperture: "光圈",
        infoShutter: "快门",
        infoTakenAt: "拍摄时间",
        login: "登录",
        register: "注册",
        sendCode: "发送验证码",
        emailPlaceholder: "邮箱",
        passwordPlaceholder: "密码",
        codePlaceholder: "验证码",
        screening: "正在初筛...",
        subscriptionTitle: "选择订阅服务",
        subscriptionNote: "每个账号可以免费试用 1 星期。试用结束后需要订阅继续使用。",
        trialButton: "开始免费试用 1 星期",
        trialUsed: "这个账号已经使用过试用，请选择订阅。",
        paymentMissing: "支付链接还没有配置。后面接支付后台后，这里会自动跳转。",
      },
      en: {
        chooseFolder: "Choose Folder",
        preflight: "Preflight",
        invite: "Invite",
        inviteCopied: "Invite link copied. Send it to someone on the same Wi-Fi.",
        inviteFailed: "Invite failed",
        profile: "Profile",
        subscribe: "Subscribe",
        switchAccount: "Switch account",
        logout: "Log out",
        profileTitle: "Profile",
        email: "Email",
        role: "Role",
        membership: "Membership",
        trialLeft: "Trial left",
        expiresAt: "Expires",
        adminRole: "Admin",
        guestRole: "Guest",
        userRole: "User",
        trialStatus: "Trial",
        paidStatus: "Paid member",
        expiredStatus: "Trial expired",
        notStartedStatus: "Trial not started",
        adminStatus: "Admin account",
        guestStatus: "Guest viewer",
        cloud: "Cloud",
        cloudDisabled: "Not configured",
        cloudConnected: "Connected",
        cloudPending: "Pending",
        reload: "Refresh",
        langButton: "中文",
        mode: "Mode",
        copyMode: "Copy",
        moveMode: "Move",
        empty: "Choose a folder to start culling.",
        noFolder: "Choose a photo folder first.",
        choosing: "Choosing folder...",
        appName: "Frame Lock",
        previous: "Previous",
        like: "Keep L",
        reject: "Pass D",
        maybe: "Maybe M",
        next: "Skip",
        undo: "Undo Z",
        fit: "Fit",
        imageError: "This photo cannot be displayed in the browser, but it can still be sorted.",
        loadingTitle: "Opening cull box",
        completeTitle: "Cull complete",
        selectedCount: "selected",
        infoTitle: "Photo info",
        infoLoading: "Reading info...",
        infoUnavailable: "Not found",
        infoIso: "ISO",
        infoAperture: "Aperture",
        infoShutter: "Shutter",
        infoTakenAt: "Taken",
        login: "Log in",
        register: "Register",
        sendCode: "Send code",
        emailPlaceholder: "Email",
        passwordPlaceholder: "Password",
        codePlaceholder: "Code",
        screening: "Preflighting...",
        subscriptionTitle: "Choose a subscription",
        subscriptionNote: "Each account gets a 1-week free trial. Subscribe after the trial to keep using the app.",
        trialButton: "Start 1-week free trial",
        trialUsed: "This account has already used the trial. Choose a subscription.",
        paymentMissing: "Payment link is not configured yet.",
      },
    };

    const state = {
      photos: [],
      index: 0,
      count: 0,
      totalCount: 0,
      completed: false,
      decisionCounts: { like: 0, reject: 0, maybe: 0 },
      sourceDir: "",
      message: "",
      messageEn: "",
      mode: "copy",
      modeLabel: "复制",
      modeLabelEn: "Copy",
      canUndo: false,
      lang: localStorage.getItem("frameLockLang") || "zh",
      zoomMode: "fit",
      zoomLevel: 100,
      isDragging: false,
      dragStartX: 0,
      dragStartY: 0,
      dragScrollLeft: 0,
      dragScrollTop: 0,
      finishShownFor: "",
      selectedIndexes: new Set(),
      anchorIndex: 0,
      listDirty: true,
      renderedRows: [],
      previousIndex: -1,
      previousSelectedIndexes: new Set(),
      currentImageIndex: -1,
      currentImageUrl: "",
      preloadImages: [],
      infoPanelOpen: false,
      infoLoading: false,
      currentInfo: null,
      infoIndex: -1,
      authMode: "login",
      authenticated: false,
      role: "",
      email: "",
      inviteLink: "",
      subscription: { active: false, kind: "" },
      cloud: { enabled: false },
      subscriptionCurrency: "eur",
      subscriptionForceOpen: false,
      profileOpen: false,
    };
    const els = {
      authGate: document.getElementById("authGate"),
      loginTab: document.getElementById("loginTab"),
      registerTab: document.getElementById("registerTab"),
      authEmail: document.getElementById("authEmail"),
      authPassword: document.getElementById("authPassword"),
      authCode: document.getElementById("authCode"),
      authMain: document.getElementById("authMain"),
      sendCode: document.getElementById("sendCode"),
      authMessage: document.getElementById("authMessage"),
      subscriptionGate: document.getElementById("subscriptionGate"),
      subscriptionClose: document.getElementById("subscriptionClose"),
      subscriptionNote: document.getElementById("subscriptionNote"),
      currencyEur: document.getElementById("currencyEur"),
      currencyUsd: document.getElementById("currencyUsd"),
      currencyCny: document.getElementById("currencyCny"),
      startTrial: document.getElementById("startTrial"),
      plans: document.getElementById("plans"),
      subscriptionMessage: document.getElementById("subscriptionMessage"),
      chooseFolder: document.getElementById("chooseFolder"),
      preflight: document.getElementById("preflight"),
      inviteButton: document.getElementById("inviteButton"),
      profileButton: document.getElementById("profileButton"),
      subscribeButton: document.getElementById("subscribeButton"),
      reload: document.getElementById("reload"),
      modeButton: document.getElementById("modeButton"),
      langButton: document.getElementById("langButton"),
      status: document.getElementById("status"),
      list: document.getElementById("list"),
      name: document.getElementById("name"),
      count: document.getElementById("count"),
      infoButton: document.getElementById("infoButton"),
      infoPanel: document.getElementById("infoPanel"),
      viewer: document.getElementById("viewer"),
      photo: document.getElementById("photo"),
      empty: document.getElementById("empty"),
      motionOverlay: document.getElementById("motionOverlay"),
      motionCard: document.getElementById("motionCard"),
      prev: document.getElementById("prev"),
      like: document.getElementById("like"),
      reject: document.getElementById("reject"),
      maybe: document.getElementById("maybe"),
      next: document.getElementById("next"),
      undo: document.getElementById("undo"),
      zoomOut: document.getElementById("zoomOut"),
      zoomRange: document.getElementById("zoomRange"),
      zoomValue: document.getElementById("zoomValue"),
      zoomIn: document.getElementById("zoomIn"),
      fitZoom: document.getElementById("fitZoom"),
      profilePanel: document.getElementById("profilePanel"),
      profileGrid: document.getElementById("profileGrid"),
      profileClose: document.getElementById("profileClose"),
      profileSubscribe: document.getElementById("profileSubscribe"),
      switchAccount: document.getElementById("switchAccount"),
      logoutButton: document.getElementById("logoutButton"),
    };

    function renderAuth() {
      els.authGate.classList.toggle("hidden", state.authenticated);
      els.loginTab.classList.toggle("active", state.authMode === "login");
      els.registerTab.classList.toggle("active", state.authMode === "register");
      els.authCode.style.display = state.authMode === "register" ? "block" : "none";
      els.sendCode.style.display = state.authMode === "register" ? "block" : "none";
      els.authMain.textContent = state.authMode === "login" ? t("login") : t("register");
      els.loginTab.textContent = t("login");
      els.registerTab.textContent = t("register");
      els.sendCode.textContent = t("sendCode");
      els.authEmail.placeholder = t("emailPlaceholder");
      els.authPassword.placeholder = t("passwordPlaceholder");
      els.authCode.placeholder = t("codePlaceholder");
    }

    function renderSubscription() {
      const show = state.authenticated && (state.subscriptionForceOpen || !(state.subscription && state.subscription.active));
      els.subscriptionGate.classList.toggle("show", show);
      els.subscriptionClose.style.display = state.subscriptionForceOpen && state.subscription && state.subscription.active ? "block" : "none";
      els.subscriptionNote.textContent = t("subscriptionNote");
      els.startTrial.textContent = t("trialButton");
      els.currencyEur.classList.toggle("active", state.subscriptionCurrency === "eur");
      els.currencyUsd.classList.toggle("active", state.subscriptionCurrency === "usd");
      els.currencyCny.classList.toggle("active", state.subscriptionCurrency === "cny");

      const plans = state.subscriptionCurrency === "cny"
        ? [
            ["cn_month", "1 个月", "25 CNY"],
            ["cn_3m", "3 个月", "55 CNY"],
            ["cn_6m", "6 个月", "100 CNY"],
            ["cn_year", "1 年", "185 CNY"],
          ]
        : state.subscriptionCurrency === "usd"
        ? [
            ["usd_month", "1 month", "4.5 USD"],
            ["usd_3m", "3 months", "11.5 USD"],
            ["usd_6m", "6 months", "20.8 USD"],
            ["usd_year", "1 year", "35.9 USD"],
          ]
        : [
            ["eu_month", "1 month", "3.9 EUR"],
            ["eu_3m", "3 months", "9.9 EUR"],
            ["eu_6m", "6 months", "17.9 EUR"],
            ["eu_year", "1 year", "30.9 EUR"],
          ];

      els.plans.innerHTML = plans.map(([id, name, price]) => `
        <div class="plan-card">
          <div class="plan-name">${name}</div>
          <div class="plan-price">${price}</div>
          <div class="plan-payments">
            ${state.subscriptionCurrency === "cny"
              ? `<div class="payment-row"><button data-provider="wechat" data-plan="${id}">微信</button><button data-provider="alipay" data-plan="${id}">支付宝</button></div>`
              : `<button data-provider="paypal" data-plan="${id}">PayPal</button>`}
          </div>
        </div>
      `).join("");

      for (const button of els.plans.querySelectorAll("button[data-plan]")) {
        button.addEventListener("click", () => createPayment(button.dataset.plan, button.dataset.provider));
      }
    }

    function roleText() {
      if (state.role === "admin") return t("adminRole");
      if (state.role === "guest") return t("guestRole");
      return t("userRole");
    }

    function formatDateTime(timestamp) {
      if (!timestamp) return "-";
      try {
        return new Date(Number(timestamp) * 1000).toLocaleString();
      } catch (_) {
        return "-";
      }
    }

    function membershipText() {
      const sub = state.subscription || {};
      if (sub.kind === "admin") return t("adminStatus");
      if (sub.kind === "guest") return t("guestStatus");
      if (sub.kind === "trial") return `${t("trialStatus")} · ${sub.daysLeft || 0} 天`;
      if (sub.kind === "paid") return `${t("paidStatus")} · ${sub.daysLeft || 0} 天`;
      if (sub.kind === "expired") return t("expiredStatus");
      return t("notStartedStatus");
    }

    function cloudText() {
      const cloud = state.cloud || {};
      if (!cloud.enabled) return t("cloudDisabled");
      if (cloud.connected) return `${t("cloudConnected")} · ${cloud.cloudUrl || ""}`;
      return `${t("cloudPending")} · ${cloud.cloudUrl || ""}`;
    }

    function renderProfile() {
      els.profilePanel.classList.toggle("show", state.profileOpen);
      if (!state.profileOpen) return;
      els.profileSubscribe.textContent = t("subscribe");
      els.switchAccount.textContent = t("switchAccount");
      els.logoutButton.textContent = t("logout");
      const sub = state.subscription || {};
      els.profileGrid.innerHTML = `
        <div class="profile-row"><div class="profile-label">${t("email")}</div><div class="profile-value">${escapeHtml(state.email || "-")}</div></div>
        <div class="profile-row"><div class="profile-label">${t("role")}</div><div class="profile-value">${escapeHtml(roleText())}</div></div>
        <div class="profile-row"><div class="profile-label">${t("membership")}</div><div class="profile-value">${escapeHtml(membershipText())}</div></div>
        <div class="profile-row"><div class="profile-label">${t("cloud")}</div><div class="profile-value">${escapeHtml(cloudText())}</div></div>
        <div class="profile-row"><div class="profile-label">${t("trialLeft")}</div><div class="profile-value">${sub.kind === "trial" ? `${sub.daysLeft || 0} 天` : "-"}</div></div>
        <div class="profile-row"><div class="profile-label">${t("expiresAt")}</div><div class="profile-value">${formatDateTime(sub.expiresAt || sub.trialEndsAt)}</div></div>
      `;
    }

    function openProfile() {
      state.profileOpen = true;
      renderProfile();
    }

    function closeProfile() {
      state.profileOpen = false;
      renderProfile();
    }

    function openSubscriptionWindow() {
      state.subscriptionForceOpen = true;
      renderSubscription();
    }

    function closeSubscriptionWindow() {
      state.subscriptionForceOpen = false;
      renderSubscription();
    }

    async function logout() {
      await requestJson("/api/auth/logout", { method: "POST" });
      state.authenticated = false;
      state.email = "";
      state.role = "";
      state.subscription = { active: false, kind: "" };
      state.cloud = { enabled: false };
      state.subscriptionForceOpen = false;
      state.profileOpen = false;
      state.photos = [];
      state.index = 0;
      state.selectedIndexes = new Set();
      state.listDirty = true;
      els.authPassword.value = "";
      els.authCode.value = "";
      renderAuth();
      renderSubscription();
      renderProfile();
      render();
    }

    async function refreshSubscription() {
      const data = await requestJson("/api/subscription-state");
      state.subscription = data.subscription || { active: false, kind: "" };
      renderSubscription();
      if (state.subscription.active) {
        await loadSnapshot();
      }
    }

    async function startTrial() {
      const data = await requestJson("/api/subscription/start-trial", { method: "POST" });
      if (!data.ok) {
        els.subscriptionMessage.textContent = state.lang === "en" ? data.messageEn || data.message || t("trialUsed") : data.message || data.messageEn || t("trialUsed");
        return;
      }
      state.subscription = data.subscription || { active: false };
      renderSubscription();
      await loadSnapshot();
    }

    async function createPayment(plan, provider) {
      const data = await requestJson("/api/subscription/payment", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ plan, provider }),
      });
      if (data.ok && data.url) {
        window.open(data.url, "_blank");
        return;
      }
      els.subscriptionMessage.textContent = state.lang === "en" ? data.messageEn || data.message || t("paymentMissing") : data.message || data.messageEn || t("paymentMissing");
    }

    async function checkAuth() {
      try {
        const invite = new URLSearchParams(window.location.search).get("invite");
        if (invite) {
          await acceptInvite(invite);
          window.history.replaceState({}, "", window.location.pathname);
          return;
        }
        const data = await requestJson("/api/auth-state");
        state.authenticated = Boolean(data.authenticated);
        state.email = data.email || "";
        state.role = data.role || "";
        state.subscription = data.subscription || { active: false };
        state.cloud = data.cloud || { enabled: false };
        renderAuth();
        renderProfile();
        if (state.authenticated) {
          renderSubscription();
          if (state.subscription.active) await loadSnapshot();
        }
      } catch (error) {
        els.authMessage.textContent = error.message;
        renderAuth();
      }
    }

    function setAuthMode(mode) {
      state.authMode = mode;
      els.authMessage.textContent = "";
      renderAuth();
    }

    async function sendAuthCode() {
      els.authMessage.textContent = "";
      const data = await requestJson("/api/auth/register-start", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email: els.authEmail.value, password: els.authPassword.value }),
      });
      els.authMessage.textContent = state.lang === "en" ? data.messageEn || data.message : data.message || data.messageEn || "";
      if (data.debugCode) els.authCode.value = data.debugCode;
    }

    async function submitAuth() {
      els.authMessage.textContent = "";
      if (state.authMode === "login") {
        const data = await requestJson("/api/auth/login", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ email: els.authEmail.value, password: els.authPassword.value }),
        });
        state.authenticated = Boolean(data.ok);
        state.email = data.email || "";
        state.role = data.role || "";
        state.subscription = data.subscription || { active: false };
        state.cloud = data.cloud || { enabled: false };
        if (!data.ok) {
          els.authMessage.textContent = state.lang === "en" ? data.messageEn || data.message : data.message || data.messageEn || "";
          renderAuth();
          return;
        }
        renderAuth();
        renderSubscription();
        if (state.subscription.active) await loadSnapshot();
        return;
      }

      const data = await requestJson("/api/auth/register-confirm", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email: els.authEmail.value, code: els.authCode.value }),
      });
      els.authMessage.textContent = state.lang === "en" ? data.messageEn || data.message : data.message || data.messageEn || "";
      if (data.ok) setAuthMode("login");
    }

    async function acceptInvite(token) {
      const data = await requestJson("/api/invite-login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ token }),
      });
      state.authenticated = Boolean(data.ok);
      state.email = data.email || "";
      state.role = data.role || "";
      state.subscription = data.subscription || { active: false };
      state.cloud = data.cloud || { enabled: false };
      if (!data.ok) {
        els.authMessage.textContent = state.lang === "en" ? data.messageEn || data.message : data.message || data.messageEn || "";
        renderAuth();
        return;
      }
      renderAuth();
      renderSubscription();
      if (state.subscription.active) await loadSnapshot();
    }

    async function createInvite() {
      const data = await requestJson("/api/invite", { method: "POST" });
      if (!data.ok) {
        els.status.textContent = state.lang === "en" ? data.messageEn || data.message || t("inviteFailed") : data.message || data.messageEn || t("inviteFailed");
        return;
      }
      state.inviteLink = data.url || "";
      if (navigator.clipboard && state.inviteLink) {
        await navigator.clipboard.writeText(state.inviteLink);
      }
      els.status.textContent = `${t("inviteCopied")}：${state.inviteLink}`;
    }

    function normalizeSelection(reset = false) {
      const maxIndex = state.photos.length - 1;
      if (reset || state.selectedIndexes.size === 0) {
        state.selectedIndexes = maxIndex >= 0 ? new Set([state.index]) : new Set();
        state.anchorIndex = state.index;
        return;
      }
      state.selectedIndexes = new Set(
        [...state.selectedIndexes].filter(index => index >= 0 && index <= maxIndex)
      );
      if (state.selectedIndexes.size === 0 && maxIndex >= 0) {
        state.selectedIndexes = new Set([state.index]);
      }
      if (state.anchorIndex < 0 || state.anchorIndex > maxIndex) {
        state.anchorIndex = state.index;
      }
    }

    function selectedIndexList() {
      return [...state.selectedIndexes].sort((a, b) => a - b);
    }

    function applySnapshot(data, options = {}) {
      const previousSourceDir = state.sourceDir;
      const previousTotalCount = state.totalCount;
      const wasCompleted = state.completed;
      state.photos = data.photos || [];
      state.index = data.index || 0;
      state.count = data.count || state.photos.length;
      state.totalCount = data.totalCount || state.count;
      state.completed = Boolean(data.completed);
      state.decisionCounts = data.decisionCounts || { like: 0, reject: 0, maybe: 0 };
      state.sourceDir = data.sourceDir || "";
      state.message = data.message || "";
      state.messageEn = data.messageEn || "";
      state.mode = data.mode || "copy";
      state.modeLabel = data.modeLabel || "复制";
      state.modeLabelEn = data.modeLabelEn || "Copy";
      state.canUndo = Boolean(data.canUndo);
      state.listDirty = state.listDirty || options.listChanged !== false || Boolean(options.resetSelection);
      normalizeSelection(Boolean(options.resetSelection));
      render();

      const loadedNewFolder = state.sourceDir && state.totalCount > 0 && (
        state.sourceDir !== previousSourceDir || previousTotalCount === 0
      );
      if (loadedNewFolder) {
        state.finishShownFor = "";
        playLoadAnimation();
      }

      if (!state.completed) {
        state.finishShownFor = "";
      }

      const finishKey = `${state.sourceDir}:${state.totalCount}`;
      if (!loadedNewFolder && !wasCompleted && state.completed && state.finishShownFor !== finishKey) {
        state.finishShownFor = finishKey;
        playFinishAnimation();
      }
    }

    function t(key) {
      return copy[state.lang][key] || copy.zh[key] || key;
    }

    function localizedMessage() {
      if (state.lang === "en") return state.messageEn || state.message;
      return state.message;
    }

    function localizedModeLabel() {
      if (state.lang === "en") return state.modeLabelEn || (state.mode === "move" ? t("moveMode") : t("copyMode"));
      return state.modeLabel || (state.mode === "move" ? t("moveMode") : t("copyMode"));
    }

    function localizedDecision(photo) {
      if (!photo) return "";
      return state.lang === "en" ? photo.decisionLabelEn || photo.decisionLabel || "" : photo.decisionLabel || "";
    }

    function escapeHtml(value) {
      return String(value || "")
        .replaceAll("&", "&amp;")
        .replaceAll("<", "&lt;")
        .replaceAll(">", "&gt;")
        .replaceAll('"', "&quot;");
    }

    function infoValue(value) {
      return value ? escapeHtml(value) : t("infoUnavailable");
    }

    function renderInfoPanel() {
      els.infoPanel.classList.toggle("show", state.infoPanelOpen);
      if (!state.infoPanelOpen) return;

      const current = state.photos[state.index];
      const info = state.currentInfo || {};
      const title = current ? current.name : t("infoTitle");
      const message = state.infoLoading
        ? t("infoLoading")
        : (state.lang === "en" ? info.messageEn || info.message || "" : info.message || "");

      els.infoPanel.innerHTML = `
        <div class="info-head">
          <div class="info-title">${escapeHtml(t("infoTitle"))} · ${escapeHtml(title)}</div>
          <button class="info-close" id="infoClose" type="button">×</button>
        </div>
        <div class="info-grid">
          <div class="info-item"><div class="info-label">${escapeHtml(t("infoIso"))}</div><div class="info-value">${infoValue(info.iso)}</div></div>
          <div class="info-item"><div class="info-label">${escapeHtml(t("infoAperture"))}</div><div class="info-value">${infoValue(info.aperture)}</div></div>
          <div class="info-item"><div class="info-label">${escapeHtml(t("infoShutter"))}</div><div class="info-value">${infoValue(info.shutter)}</div></div>
          <div class="info-item"><div class="info-label">${escapeHtml(t("infoTakenAt"))}</div><div class="info-value">${infoValue(info.takenAt)}</div></div>
        </div>
        ${message ? `<div class="info-message">${escapeHtml(message)}</div>` : ""}
      `;
      const close = document.getElementById("infoClose");
      if (close) close.addEventListener("click", closeInfoPanel);
    }

    async function loadPhotoInfo() {
      if (state.photos.length === 0) return;
      const index = state.index;
      state.infoLoading = true;
      state.currentInfo = null;
      state.infoIndex = index;
      renderInfoPanel();
      try {
        state.currentInfo = await requestJson(`/api/photo-info?index=${index}`);
      } catch (error) {
        state.currentInfo = { message: error.message, messageEn: error.message };
      } finally {
        state.infoLoading = false;
        state.infoIndex = index;
        renderInfoPanel();
      }
    }

    function openInfoPanel() {
      if (state.photos.length === 0) return;
      state.infoPanelOpen = true;
      loadPhotoInfo();
    }

    function closeInfoPanel() {
      state.infoPanelOpen = false;
      renderInfoPanel();
    }

    function toggleInfoPanel() {
      if (state.infoPanelOpen) {
        closeInfoPanel();
      } else {
        openInfoPanel();
      }
    }

    function wait(ms) {
      return new Promise(resolve => setTimeout(resolve, ms));
    }

    async function playLoadAnimation() {
      if (!els.motionOverlay || state.totalCount === 0) return;
      els.motionOverlay.className = "motion-overlay show loading";
      els.motionCard.innerHTML = `
        <div class="file-box"></div>
        <div class="motion-photo p1"></div>
        <div class="motion-photo p2"></div>
        <div class="motion-photo p3"></div>
        <div class="motion-photo p4"></div>
        <div class="motion-photo p5"></div>
        <div class="motion-title">${t("loadingTitle")} · ${state.totalCount}</div>
      `;
      await wait(1850);
      els.motionOverlay.className = "motion-overlay";
      els.motionCard.innerHTML = "";
    }

    async function playFinishAnimation() {
      if (!els.motionOverlay || state.totalCount === 0) return;
      const counts = state.decisionCounts || {};
      const zhLabels = { like: "喜欢", reject: "不喜欢", maybe: "待定" };
      const enLabels = { like: "Keep", reject: "Pass", maybe: "Maybe" };
      const labelFor = category => state.lang === "en" ? enLabels[category] : zhLabels[category];
      const lane = category => `
        <div class="finish-lane ${category}">
          <div class="finish-label">${labelFor(category)} · ${counts[category] || 0}</div>
          <div class="finish-mini"></div>
          <div class="finish-mini"></div>
          <div class="finish-mini"></div>
          <div class="file-box"></div>
        </div>
      `;
      els.motionOverlay.className = "motion-overlay show done";
      els.motionCard.innerHTML = `
        <div class="finish-boxes">
          ${lane("like")}
          ${lane("reject")}
          ${lane("maybe")}
        </div>
        <div class="motion-title">${t("completeTitle")} · ${state.totalCount}</div>
      `;
      await wait(2300);
      els.motionOverlay.className = "motion-overlay";
      els.motionCard.innerHTML = "";
    }

    function applyStaticLabels() {
      els.chooseFolder.textContent = t("chooseFolder");
      els.preflight.textContent = t("preflight");
      els.inviteButton.textContent = t("invite");
      els.profileButton.textContent = t("profile");
      els.subscribeButton.textContent = t("subscribe");
      els.reload.textContent = t("reload");
      els.langButton.textContent = t("langButton");
      els.modeButton.textContent = `${t("mode")}: ${localizedModeLabel()}`;
      els.prev.textContent = t("previous");
      els.like.textContent = t("like");
      els.reject.textContent = t("reject");
      els.maybe.textContent = t("maybe");
      els.next.textContent = t("next");
      els.undo.textContent = t("undo");
      els.fitZoom.textContent = t("fit");
    }

    function classForRow(index) {
      return [
        "row",
        index === state.index ? "active" : "",
        state.selectedIndexes.has(index) ? "multi-selected" : "",
      ].filter(Boolean).join(" ");
    }

    function updateRowState(index) {
      const row = state.renderedRows[index];
      if (row) row.className = classForRow(index);
    }

    function updateVisibleRowStates() {
      if (state.listDirty) return;
      const changed = new Set([state.previousIndex, state.index]);
      for (const index of state.previousSelectedIndexes) changed.add(index);
      for (const index of state.selectedIndexes) changed.add(index);
      for (const index of changed) {
        if (Number.isInteger(index) && index >= 0) updateRowState(index);
      }
      state.previousIndex = state.index;
      state.previousSelectedIndexes = new Set(state.selectedIndexes);
    }

    function displayGroupForPhoto(photo) {
      if (photo.preflightReasons && photo.preflightReasons.length > 0) {
        return `初筛 / ${photo.preflightLabel || photo.preflightReasons.join(" / ")}`;
      }
      return photo.formatGroup || photo.extension || "OTHER";
    }

    function renderList() {
      els.list.innerHTML = "";
      state.renderedRows = [];
      const groupCounts = state.photos.reduce((counts, photo) => {
        const group = displayGroupForPhoto(photo);
        counts[group] = (counts[group] || 0) + 1;
        return counts;
      }, {});
      let previousGroup = "";
      const ordered = state.photos
        .map((photo, index) => ({ photo, index, group: displayGroupForPhoto(photo) }))
        .sort((left, right) => {
          const leftFlagged = left.photo.preflightReasons && left.photo.preflightReasons.length > 0 ? 0 : 1;
          const rightFlagged = right.photo.preflightReasons && right.photo.preflightReasons.length > 0 ? 0 : 1;
          if (leftFlagged !== rightFlagged) return leftFlagged - rightFlagged;
          if (left.group !== right.group) return left.group.localeCompare(right.group);
          return left.photo.name.localeCompare(right.photo.name);
        });
      ordered.forEach(({ photo, index, group }) => {
        if (group !== previousGroup) {
          const header = document.createElement("div");
          header.className = "group-header";
          const unit = state.lang === "en" ? "photos" : "张";
          header.innerHTML = `<span>${group}</span><span class="group-count">${groupCounts[group]} ${unit}</span>`;
          els.list.appendChild(header);
          previousGroup = group;
        }
        const row = document.createElement("button");
        row.className = classForRow(index);
        row.title = photo.path;
        row.dataset.index = String(index);
        const name = document.createElement("span");
        name.className = "row-name";
        name.textContent = photo.name;
        row.appendChild(name);
        const format = document.createElement("span");
        format.className = "badge";
        format.textContent = photo.extension || group;
        row.appendChild(format);
        const decisionLabel = localizedDecision(photo);
        if (photo.preflightLabel) {
          const preflightBadge = document.createElement("span");
          preflightBadge.className = "badge reject";
          preflightBadge.textContent = photo.preflightLabel;
          row.appendChild(preflightBadge);
        }
        if (decisionLabel) {
          const badge = document.createElement("span");
          badge.className = `badge ${photo.decision || ""}`;
          badge.textContent = decisionLabel;
          row.appendChild(badge);
        }
        row.addEventListener("click", event => selectPhoto(index, event));
        els.list.appendChild(row);
        state.renderedRows[index] = row;
      });
      state.listDirty = false;
      state.previousIndex = state.index;
      state.previousSelectedIndexes = new Set(state.selectedIndexes);
    }

    function imageUrl(index) {
      const photo = state.photos[index];
      return photo ? `/image/${index}?p=${encodeURIComponent(photo.path)}` : "";
    }

    function preloadNeighborImages() {
      if (state.photos.length === 0) return;
      const indexes = [state.index + 1, state.index - 1].filter(index => index >= 0 && index < state.photos.length);
      state.preloadImages = indexes.map(index => {
        const image = new Image();
        image.src = imageUrl(index);
        return image;
      });
    }

    function render() {
      const hasPhotos = state.photos.length > 0;
      const canEdit = state.role !== "guest";
      applyStaticLabels();
      els.status.textContent = localizedMessage() || (hasPhotos ? state.sourceDir : t("noFolder"));
      els.modeButton.textContent = `${t("mode")}: ${localizedModeLabel()}`;
      els.modeButton.classList.toggle("mode-move", state.mode === "move");
      if (state.listDirty) renderList();
      updateVisibleRowStates();

      const current = state.photos[state.index];
      const currentDecision = localizedDecision(current);
      const selectedCount = state.selectedIndexes.size;
      const selectedText = selectedCount > 1 ? ` · ${selectedCount} ${t("selectedCount")}` : "";
      els.name.textContent = current ? current.name : t("appName");
      els.count.textContent = hasPhotos ? `${state.index + 1} / ${state.photos.length}${currentDecision ? " · " + currentDecision : ""}${selectedText}` : "";
      els.infoButton.disabled = !hasPhotos;
      els.infoButton.title = t("infoTitle");
      if (!hasPhotos) {
        state.infoPanelOpen = false;
      }
      renderInfoPanel();
      els.photo.style.display = hasPhotos ? "block" : "none";
      els.empty.style.display = hasPhotos ? "none" : "block";
      els.empty.textContent = t("empty");
      if (hasPhotos) {
        const nextSrc = imageUrl(state.index);
        if (state.currentImageIndex !== state.index || state.currentImageUrl !== nextSrc) {
          state.currentImageIndex = state.index;
          state.currentImageUrl = nextSrc;
          els.photo.src = nextSrc;
        }
      } else {
        els.photo.removeAttribute("src");
        state.currentImageIndex = -1;
        state.currentImageUrl = "";
      }

      for (const button of [els.prev, els.like, els.reject, els.maybe, els.next, els.zoomOut, els.zoomIn, els.fitZoom, els.preflight]) {
        button.disabled = !hasPhotos;
      }
      for (const button of [els.chooseFolder, els.like, els.reject, els.maybe, els.undo, els.preflight, els.modeButton]) {
        button.disabled = button.disabled || !canEdit;
      }
      els.inviteButton.disabled = state.role !== "admin";
      els.profileButton.disabled = !state.authenticated;
      els.subscribeButton.disabled = !state.authenticated || state.role === "admin" || state.role === "guest";
      els.zoomRange.disabled = !hasPhotos;
      els.prev.disabled = !hasPhotos || state.index === 0;
      els.next.disabled = !hasPhotos || state.index >= state.photos.length - 1;
      els.undo.disabled = !state.canUndo;
      applyZoomState();

      requestAnimationFrame(() => {
        const active = els.list.querySelector(".active");
        if (active) active.scrollIntoView({ block: "nearest" });
      });
    }

    async function requestJson(url, options = {}) {
      const response = await fetch(url, options);
      if (!response.ok) throw new Error(await response.text());
      return response.json();
    }

    async function loadSnapshot() {
      applySnapshot(await requestJson("/api/state"), { resetSelection: true });
    }

    async function chooseFolder() {
      els.status.textContent = t("choosing");
      applySnapshot(await requestJson("/api/choose-folder", { method: "POST" }), { resetSelection: true });
    }

    async function runPreflight() {
      if (state.photos.length === 0) return;
      els.status.textContent = t("screening");
      applySnapshot(await requestJson("/api/preflight", { method: "POST" }));
    }

    async function setIndex(index, options = {}) {
      if (state.photos.length === 0) return;
      state.index = Math.max(0, Math.min(index, state.photos.length - 1));
      if (options.resetSelection) {
        normalizeSelection(true);
      } else {
        normalizeSelection(false);
      }
      render();
      if (state.infoPanelOpen && state.infoIndex !== state.index) {
        loadPhotoInfo();
      }
    }

    async function selectPhoto(index, event = null) {
      const additive = Boolean(event && (event.ctrlKey || event.metaKey));
      const ranged = Boolean(event && event.shiftKey);

      if (ranged && state.photos.length > 0) {
        const start = Math.min(state.anchorIndex, index);
        const end = Math.max(state.anchorIndex, index);
        state.selectedIndexes = new Set(Array.from({ length: end - start + 1 }, (_, offset) => start + offset));
      } else if (additive) {
        const next = new Set(state.selectedIndexes);
        if (next.has(index) && next.size > 1) {
          next.delete(index);
        } else {
          next.add(index);
        }
        state.selectedIndexes = next;
        state.anchorIndex = index;
      } else {
        state.selectedIndexes = new Set([index]);
        state.anchorIndex = index;
      }

      await setIndex(index);
    }

    async function moveKeyboardSelection(delta, event) {
      if (state.photos.length === 0) return;
      const nextIndex = Math.max(0, Math.min(state.index + delta, state.photos.length - 1));
      if (nextIndex === state.index && !event.shiftKey) return;

      if (event.shiftKey) {
        const start = Math.min(state.anchorIndex, nextIndex);
        const end = Math.max(state.anchorIndex, nextIndex);
        state.selectedIndexes = new Set(Array.from({ length: end - start + 1 }, (_, offset) => start + offset));
      } else {
        state.selectedIndexes = new Set([nextIndex]);
        state.anchorIndex = nextIndex;
      }

      await setIndex(nextIndex);
    }

    async function categorize(category) {
      const indexes = selectedIndexList();
      applySnapshot(await requestJson("/api/categorize", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ category, index: state.index, indexes }),
      }), { resetSelection: true });
    }

    async function setMode(mode) {
      applySnapshot(await requestJson("/api/mode", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ mode }),
      }), { listChanged: false });
    }

    async function undo() {
      applySnapshot(await requestJson("/api/undo", { method: "POST" }), { resetSelection: true });
    }

    function toggleMode() {
      setMode(state.mode === "move" ? "copy" : "move");
    }

    function toggleLanguage() {
      state.lang = state.lang === "zh" ? "en" : "zh";
      localStorage.setItem("frameLockLang", state.lang);
      state.listDirty = true;
      render();
    }

    function clampZoom(level) {
      return Math.max(10, Math.min(400, level));
    }

    function formatZoom(level) {
      return `${Math.round(level)}%`;
    }

    function applyZoomState() {
      const manual = state.zoomMode === "manual";
      els.viewer.classList.toggle("zoomed", manual);
      els.zoomRange.value = String(state.zoomLevel);
      els.zoomValue.textContent = manual ? formatZoom(state.zoomLevel) : t("fit");
      els.fitZoom.disabled = state.photos.length === 0 || !manual;

      if (!manual) {
        els.photo.style.width = "100%";
        els.photo.style.height = "100%";
        els.photo.style.objectFit = "contain";
        return;
      }

      const naturalWidth = els.photo.naturalWidth || 0;
      const naturalHeight = els.photo.naturalHeight || 0;
      if (naturalWidth > 0 && naturalHeight > 0) {
        els.photo.style.width = `${naturalWidth * state.zoomLevel / 100}px`;
        els.photo.style.height = "auto";
        els.photo.style.objectFit = "initial";
      }
    }

    function currentImageMetrics() {
      const viewerWidth = els.viewer.clientWidth || 1;
      const viewerHeight = els.viewer.clientHeight || 1;
      const naturalWidth = els.photo.naturalWidth || viewerWidth;
      const naturalHeight = els.photo.naturalHeight || viewerHeight;

      if (state.zoomMode === "manual") {
        return {
          width: els.photo.offsetWidth || 1,
          height: els.photo.offsetHeight || 1,
          offsetX: 0,
          offsetY: 0,
        };
      }

      const fitScale = Math.min(viewerWidth / naturalWidth, viewerHeight / naturalHeight);
      const width = naturalWidth * fitScale;
      const height = naturalHeight * fitScale;
      return {
        width,
        height,
        offsetX: Math.max(0, (viewerWidth - width) / 2),
        offsetY: Math.max(0, (viewerHeight - height) / 2),
      };
    }

    function setZoom(level, anchorEvent = null, options = {}) {
      if (state.photos.length === 0) return;

      const metrics = currentImageMetrics();
      const rect = els.viewer.getBoundingClientRect();
      const anchorX = anchorEvent ? anchorEvent.clientX - rect.left : rect.width / 2;
      const anchorY = anchorEvent ? anchorEvent.clientY - rect.top : rect.height / 2;
      const imageAnchorX = Math.max(0, Math.min(metrics.width, anchorX - metrics.offsetX));
      const imageAnchorY = Math.max(0, Math.min(metrics.height, anchorY - metrics.offsetY));
      const ratioX = (els.viewer.scrollLeft + imageAnchorX) / metrics.width;
      const ratioY = (els.viewer.scrollTop + imageAnchorY) / metrics.height;

      state.zoomMode = "manual";
      state.zoomLevel = clampZoom(options.snap ? Math.round(level / 5) * 5 : level);
      applyZoomState();

      const applyAnchoredScroll = () => {
        els.viewer.scrollLeft = Math.max(0, ratioX * els.photo.offsetWidth - anchorX);
        els.viewer.scrollTop = Math.max(0, ratioY * els.photo.offsetHeight - anchorY);
      };
      applyAnchoredScroll();
      requestAnimationFrame(applyAnchoredScroll);
    }

    function fitZoom() {
      state.zoomMode = "fit";
      applyZoomState();
      els.viewer.scrollLeft = 0;
      els.viewer.scrollTop = 0;
    }

    function startDrag(event) {
      if (state.zoomMode !== "manual" || event.button !== 0) return;
      event.preventDefault();
      state.isDragging = true;
      state.dragStartX = event.clientX;
      state.dragStartY = event.clientY;
      state.dragScrollLeft = els.viewer.scrollLeft;
      state.dragScrollTop = els.viewer.scrollTop;
      els.viewer.classList.add("dragging");
      els.viewer.setPointerCapture(event.pointerId);
    }

    function dragImage(event) {
      if (!state.isDragging) return;
      event.preventDefault();
      els.viewer.scrollLeft = state.dragScrollLeft - (event.clientX - state.dragStartX);
      els.viewer.scrollTop = state.dragScrollTop - (event.clientY - state.dragStartY);
    }

    function stopDrag(event) {
      if (!state.isDragging) return;
      state.isDragging = false;
      els.viewer.classList.remove("dragging");
      if (event.pointerId !== undefined && els.viewer.hasPointerCapture(event.pointerId)) {
        els.viewer.releasePointerCapture(event.pointerId);
      }
    }

    els.loginTab.addEventListener("click", () => setAuthMode("login"));
    els.registerTab.addEventListener("click", () => setAuthMode("register"));
    els.sendCode.addEventListener("click", () => sendAuthCode().catch(error => { els.authMessage.textContent = error.message; }));
    els.authMain.addEventListener("click", () => submitAuth().catch(error => { els.authMessage.textContent = error.message; }));
    els.authPassword.addEventListener("keydown", event => {
      if (event.key === "Enter") submitAuth().catch(error => { els.authMessage.textContent = error.message; });
    });
    els.authCode.addEventListener("keydown", event => {
      if (event.key === "Enter") submitAuth().catch(error => { els.authMessage.textContent = error.message; });
    });
    els.currencyEur.addEventListener("click", () => {
      state.subscriptionCurrency = "eur";
      els.subscriptionMessage.textContent = "";
      renderSubscription();
    });
    els.currencyUsd.addEventListener("click", () => {
      state.subscriptionCurrency = "usd";
      els.subscriptionMessage.textContent = "";
      renderSubscription();
    });
    els.currencyCny.addEventListener("click", () => {
      state.subscriptionCurrency = "cny";
      els.subscriptionMessage.textContent = "";
      renderSubscription();
    });
    els.subscriptionClose.addEventListener("click", closeSubscriptionWindow);
    els.startTrial.addEventListener("click", () => startTrial().catch(error => { els.subscriptionMessage.textContent = error.message; }));
    els.chooseFolder.addEventListener("click", chooseFolder);
    els.preflight.addEventListener("click", () => runPreflight().catch(error => { els.status.textContent = error.message; }));
    els.inviteButton.addEventListener("click", () => createInvite().catch(error => { els.status.textContent = error.message; }));
    els.profileButton.addEventListener("click", openProfile);
    els.subscribeButton.addEventListener("click", openSubscriptionWindow);
    els.profileClose.addEventListener("click", closeProfile);
    els.profileSubscribe.addEventListener("click", () => {
      closeProfile();
      openSubscriptionWindow();
    });
    els.switchAccount.addEventListener("click", () => logout().catch(error => { els.status.textContent = error.message; }));
    els.logoutButton.addEventListener("click", () => logout().catch(error => { els.status.textContent = error.message; }));
    els.reload.addEventListener("click", loadSnapshot);
    els.modeButton.addEventListener("click", toggleMode);
    els.langButton.addEventListener("click", toggleLanguage);
    els.infoButton.addEventListener("click", toggleInfoPanel);
    els.prev.addEventListener("click", () => setIndex(state.index - 1, { resetSelection: true }));
    els.next.addEventListener("click", () => setIndex(state.index + 1, { resetSelection: true }));
    els.like.addEventListener("click", () => categorize("like"));
    els.reject.addEventListener("click", () => categorize("reject"));
    els.maybe.addEventListener("click", () => categorize("maybe"));
    els.undo.addEventListener("click", undo);
    els.zoomOut.addEventListener("click", () => setZoom(state.zoomLevel - 10, null, { snap: true }));
    els.zoomIn.addEventListener("click", () => setZoom(state.zoomLevel + 10, null, { snap: true }));
    els.fitZoom.addEventListener("click", fitZoom);
    els.zoomRange.addEventListener("input", () => setZoom(Number(els.zoomRange.value), null, { snap: true }));
    els.viewer.addEventListener("wheel", (event) => {
      if (state.photos.length === 0) return;
      event.preventDefault();
      const sensitivity = event.deltaMode === 1 ? 0.0336 : 0.00392;
      const nextZoom = state.zoomLevel * Math.exp(-event.deltaY * sensitivity);
      setZoom(nextZoom, event);
    }, { passive: false });
    els.viewer.addEventListener("pointerdown", startDrag);
    els.viewer.addEventListener("pointermove", dragImage);
    els.viewer.addEventListener("pointerup", stopDrag);
    els.viewer.addEventListener("pointercancel", stopDrag);
    els.viewer.addEventListener("pointerleave", stopDrag);

    document.addEventListener("keydown", (event) => {
      const key = event.key.toLowerCase();
      if (key === "arrowup") {
        event.preventDefault();
        moveKeyboardSelection(-1, event);
        return;
      }
      if (key === "arrowdown") {
        event.preventDefault();
        moveKeyboardSelection(1, event);
        return;
      }
      if (key === "arrowleft" && state.index > 0) setIndex(state.index - 1, { resetSelection: true });
      if (key === "arrowright" && state.index < state.photos.length - 1) setIndex(state.index + 1, { resetSelection: true });
      if (key === "l") categorize("like");
      if (key === "d") categorize("reject");
      if (key === "m") categorize("maybe");
      if (key === "z") undo();
      if (key === "0") fitZoom();
      if (key === "=" || key === "+") setZoom(state.zoomLevel + 10, null, { snap: true });
      if (key === "-") setZoom(state.zoomLevel - 10, null, { snap: true });
    });

    els.photo.addEventListener("load", () => {
      if (state.photos.length > 0) {
        els.photo.style.display = "block";
        els.empty.style.display = "none";
        applyZoomState();
        preloadNeighborImages();
      }
    });

    els.photo.addEventListener("error", () => {
      els.photo.style.display = "none";
      els.empty.style.display = "block";
      els.empty.textContent = t("imageError");
    });

    renderAuth();
    checkAuth().catch(error => {
      els.status.textContent = error.message;
    });
  </script>
</body>
</html>
"""


class PhotoCullerHandler(BaseHTTPRequestHandler):
    state: PhotoCullerState

    def log_message(self, format: str, *args) -> None:
        return

    def session_token(self) -> str:
        cookies = self.headers.get("Cookie", "")
        for part in cookies.split(";"):
            if "=" not in part:
                continue
            name, value = part.strip().split("=", 1)
            if name == SESSION_COOKIE:
                return urllib.parse.unquote(value)
        return ""

    def current_user(self) -> str:
        return AUTH.email_for_token(self.session_token())

    def send_unauthorized(self) -> None:
        self.send_json({"ok": False, "message": "请先登录", "messageEn": "Please log in"}, status=HTTPStatus.UNAUTHORIZED)

    def send_forbidden(self) -> None:
        self.send_json({"ok": False, "message": "访客只能观看", "messageEn": "Guests can view only"}, status=HTTPStatus.FORBIDDEN)

    def send_subscription_required(self) -> None:
        self.send_json({"ok": False, "message": "试用已结束，请订阅后继续使用", "messageEn": "Trial expired. Subscribe to continue."}, status=HTTPStatus.PAYMENT_REQUIRED)

    def current_subscription(self) -> dict:
        email = self.current_user()
        return AUTH.subscription_status(email) if email else {"active": False, "kind": ""}

    def do_GET(self) -> None:
        parsed = urllib.parse.urlparse(self.path)
        path = parsed.path
        if path == "/":
            self.send_html(HTML_PAGE)
            return
        if path == "/api/auth-state":
            email = self.current_user()
            headers = {}
            cloud_payload = cloud_session_state()
            if not email and cloud_base_url():
                restored = AUTH.restore_cloud_login()
                if restored.get("ok"):
                    email = str(restored.get("email", ""))
                    headers["Set-Cookie"] = f"{SESSION_COOKIE}={urllib.parse.quote(str(restored['token']))}; Path=/; SameSite=Lax"
                    cloud_payload = restored.get("cloud", cloud_payload)
            if email and cloud_base_url() and AUTH.role_for_email(email) not in {"guest"}:
                refreshed = cloud_refresh_session(force_activate=False)
                if refreshed.get("ok"):
                    with AUTH.lock:
                        profile = AUTH.cloud_profiles.setdefault(email, {"role": AUTH.role_for_email(email)})
                        profile["role"] = str(refreshed.get("user", {}).get("role", profile.get("role", "user"))) if isinstance(refreshed.get("user"), dict) else str(profile.get("role", "user"))
                        profile["subscription"] = refreshed.get("subscription", {}) or {}
                    cloud_payload = {
                        "enabled": True,
                        "connected": True,
                        "cloudUrl": cloud_base_url(),
                        "email": email,
                        "subscription": refreshed.get("subscription", {}) or {},
                    }
            self.send_json({
                "authenticated": bool(email),
                "email": email,
                "role": AUTH.role_for_email(email) if email else "",
                "subscription": AUTH.subscription_status(email) if email else {"active": False, "kind": ""},
                "cloud": cloud_payload,
            }, extra_headers=headers)
            return
        if path == "/api/subscription-state":
            email = self.current_user()
            headers = {}
            if not email and cloud_base_url():
                restored = AUTH.restore_cloud_login()
                if restored.get("ok"):
                    email = str(restored.get("email", ""))
                    headers["Set-Cookie"] = f"{SESSION_COOKIE}={urllib.parse.quote(str(restored['token']))}; Path=/; SameSite=Lax"
            if not email:
                self.send_unauthorized()
                return
            if cloud_base_url() and AUTH.role_for_email(email) not in {"guest"}:
                refreshed = cloud_refresh_session(force_activate=False)
                if refreshed.get("ok"):
                    with AUTH.lock:
                        profile = AUTH.cloud_profiles.setdefault(email, {"role": AUTH.role_for_email(email)})
                        profile["role"] = str(refreshed.get("user", {}).get("role", profile.get("role", "user"))) if isinstance(refreshed.get("user"), dict) else str(profile.get("role", "user"))
                        profile["subscription"] = refreshed.get("subscription", {}) or {}
            self.send_json({"subscription": AUTH.subscription_status(email)}, extra_headers=headers)
            return
        if not self.current_user():
            self.send_unauthorized()
            return
        if not self.current_subscription().get("active"):
            self.send_subscription_required()
            return
        if path == "/api/state":
            self.send_json(self.state.snapshot())
            return
        if path == "/api/photo-info":
            query = urllib.parse.parse_qs(parsed.query)
            try:
                index = int(query.get("index", ["0"])[0])
            except ValueError:
                index = 0
            self.send_json(self.state.photo_info(index))
            return
        if path.startswith("/image/"):
            self.send_image(path)
            return
        self.send_error(HTTPStatus.NOT_FOUND)

    def do_POST(self) -> None:
        path = urllib.parse.urlparse(self.path).path
        if path == "/api/auth/register-start":
            body = self.read_json()
            self.send_json(AUTH.start_register(str(body.get("email", "")), str(body.get("password", ""))))
            return
        if path == "/api/auth/register-confirm":
            body = self.read_json()
            self.send_json(AUTH.confirm_register(str(body.get("email", "")), str(body.get("code", ""))))
            return
        if path == "/api/auth/login":
            body = self.read_json()
            result = AUTH.login(str(body.get("email", "")), str(body.get("password", "")))
            headers = {}
            if result.get("ok") and result.get("token"):
                headers["Set-Cookie"] = f"{SESSION_COOKIE}={urllib.parse.quote(str(result['token']))}; Path=/; SameSite=Lax"
                result["subscription"] = result.get("subscription") or AUTH.subscription_status(str(result.get("email", "")))
            self.send_json(result, extra_headers=headers)
            return
        if path == "/api/auth/logout":
            AUTH.logout(self.session_token())
            clear_cloud_session()
            self.send_json(
                {"ok": True},
                extra_headers={"Set-Cookie": f"{SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax"},
            )
            return
        if path == "/api/invite-login":
            body = self.read_json()
            result = AUTH.accept_invite(str(body.get("token", "")))
            headers = {}
            if result.get("ok") and result.get("token"):
                headers["Set-Cookie"] = f"{SESSION_COOKIE}={urllib.parse.quote(str(result['token']))}; Path=/; SameSite=Lax"
                result["subscription"] = AUTH.subscription_status(str(result.get("email", "")))
            self.send_json(result, extra_headers=headers)
            return
        if not self.current_user():
            self.send_unauthorized()
            return
        if path == "/api/subscription/start-trial":
            self.send_json(AUTH.start_trial(self.current_user()))
            return
        if path == "/api/subscription/payment":
            body = self.read_json()
            self.send_json(AUTH.payment_option(self.current_user(), str(body.get("plan", "")), str(body.get("provider", ""))))
            return
        if not self.current_subscription().get("active"):
            self.send_subscription_required()
            return
        if AUTH.role_for_email(self.current_user()) == "guest":
            self.send_forbidden()
            return
        if path == "/api/invite":
            email = self.current_user()
            result = AUTH.create_invite(email)
            if result.get("ok") and result.get("token"):
                port = self.server.server_address[1]
                result["url"] = f"http://{local_network_ip()}:{port}/?invite={urllib.parse.quote(str(result['token']))}"
            self.send_json(result)
            return
        if path == "/api/choose-folder":
            self.send_json(self.state.choose_folder())
            return
        if path == "/api/preflight":
            self.send_json(self.state.preflight_screen())
            return
        if path == "/api/index":
            body = self.read_json()
            self.send_json(self.state.set_index(int(body.get("index", 0))))
            return
        if path == "/api/categorize":
            body = self.read_json()
            category = str(body.get("category", ""))
            indexes = body.get("indexes")
            if isinstance(indexes, list):
                self.send_json(self.state.categorize_many(category, indexes))
                return
            index = body.get("index")
            self.send_json(self.state.categorize(category, int(index) if index is not None else None))
            return
        if path == "/api/mode":
            body = self.read_json()
            self.send_json(self.state.set_mode(str(body.get("mode", "copy"))))
            return
        if path == "/api/undo":
            self.send_json(self.state.undo())
            return
        self.send_error(HTTPStatus.NOT_FOUND)

    def read_json(self) -> dict:
        length = int(self.headers.get("Content-Length", "0"))
        if length <= 0:
            return {}
        raw = self.rfile.read(length)
        try:
            return json.loads(raw.decode("utf-8"))
        except json.JSONDecodeError:
            return {}

    def send_html(self, content: str) -> None:
        encoded = content.encode("utf-8")
        self.send_response(HTTPStatus.OK)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(encoded)))
        self.end_headers()
        self.wfile.write(encoded)

    def send_json(self, payload: dict, status: HTTPStatus = HTTPStatus.OK, extra_headers: dict[str, str] | None = None) -> None:
        encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        for key, value in (extra_headers or {}).items():
            self.send_header(key, value)
        self.send_header("Content-Length", str(len(encoded)))
        self.end_headers()
        self.wfile.write(encoded)

    def send_image(self, path: str) -> None:
        index_text = path.rsplit("/", 1)[-1]
        try:
            index = int(index_text)
        except ValueError:
            self.send_error(HTTPStatus.NOT_FOUND)
            return

        image_path, mime = self.state.image_path(index)
        if image_path is None or not image_path.exists():
            message = "无法显示这张照片"
            encoded = html.escape(message).encode("utf-8")
            self.send_response(HTTPStatus.NOT_FOUND)
            self.send_header("Content-Type", "text/plain; charset=utf-8")
            self.send_header("Content-Length", str(len(encoded)))
            self.end_headers()
            self.wfile.write(encoded)
            return

        self.send_response(HTTPStatus.OK)
        self.send_header("Content-Type", mime)
        self.send_header("Content-Length", str(image_path.stat().st_size))
        self.send_header("Cache-Control", "public, max-age=3600")
        self.end_headers()
        try:
            with image_path.open("rb") as handle:
                shutil.copyfileobj(handle, self.wfile)
        except BrokenPipeError:
            return


def make_handler(state: PhotoCullerState) -> type[PhotoCullerHandler]:
    class Handler(PhotoCullerHandler):
        pass

    Handler.state = state
    return Handler


def main() -> None:
    configure_console()
    state = PhotoCullerState()
    bind_host = os.getenv("DINGZHEN_BIND_HOST", "127.0.0.1").strip() or "127.0.0.1"
    try:
        server = ThreadingHTTPServer((bind_host, 0), make_handler(state))
    except PermissionError:
        bind_host = "127.0.0.1"
        server = ThreadingHTTPServer((bind_host, 0), make_handler(state))
    _, port = server.server_address
    url = f"http://127.0.0.1:{port}/"
    no_browser = os.getenv("DINGZHEN_NO_BROWSER", "") == "1"

    print_startup_messages(url)
    print(f"局域网邀请地址会使用：http://{local_network_ip()}:{port}/", flush=True)

    try:
        write_windows_url_shortcut(url)
        if not no_browser:
            open_browser(url)
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        state.close()
        server.server_close()


if __name__ == "__main__":
    main()
