#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Export the V6 prototype with BrowserSkill and rebuild the atlas.

Prerequisites:
  - a BrowserSkill session is active; pass its id in BSK_SESSION
  - bsk is on PATH
  - this script is run with the bundled Python that has Pillow

Example:
  BSK_SESSION=<session> python3 delivery/export-v6.py

The script starts its own local static server, emulates 393x852 at DPR 3,
captures every screen in delivery/screens.json, then rebuilds:
  screens/*.png, delivery/manifest.json, delivery/export-checks.json,
  图集.html, and the five overview posters.
"""
from __future__ import annotations

import argparse
import functools
import html
import http.server
import json
import math
import os
import shutil
import struct
import subprocess
import tempfile
import threading
import time
from pathlib import Path

from PIL import Image, ImageDraw, ImageFont

BASE = Path(__file__).resolve().parent.parent
SESSION = os.environ.get("BSK_SESSION", "").strip()
if not SESSION:
    raise SystemExit("Set BSK_SESSION to an active BrowserSkill session id (see `bsk session list`).")

SCREENS = json.loads((BASE / "delivery" / "screens.json").read_text(encoding="utf-8"))
TEMP = Path(tempfile.mkdtemp(prefix="bt-v6-export-"))


class BskError(RuntimeError):
    pass


def bsk(*args: str) -> dict:
    p = subprocess.run(
        ["bsk", *args, "--session", SESSION, "--json"],
        capture_output=True,
        text=True,
    )
    if p.returncode:
        raise BskError((p.stderr or p.stdout)[:1200])
    try:
        data = json.loads(p.stdout)
    except json.JSONDecodeError as exc:
        raise BskError((p.stdout or p.stderr)[:1200]) from exc
    if isinstance(data, dict) and data.get("ok") is False:
        raise BskError(json.dumps(data, ensure_ascii=False)[:1200])
    return data


def sync() -> tuple[str, str]:
    for attempt in range(5):
        try:
            d = bsk("cooperate", "sync")
            return d["context_id"], d["observation_id"]
        except (BskError, KeyError):
            if attempt == 4:
                raise
            time.sleep(0.25)
    raise AssertionError("unreachable")


def action(command: str, *args: str, attempts: int = 5) -> dict:
    last: Exception | None = None
    for attempt in range(attempts):
        ctx, obs = sync()
        try:
            return bsk(command, "--context", ctx, "--observation", obs, *args)
        except BskError as exc:
            last = exc
            if attempt == attempts - 1:
                raise
            time.sleep(0.25)
    raise last  # type: ignore[misc]


def png_size(path: Path) -> tuple[int, int]:
    with path.open("rb") as fh:
        header = fh.read(24)
    return struct.unpack(">II", header[16:24])


def start_server() -> tuple[http.server.ThreadingHTTPServer, str]:
    handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(BASE))
    httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
    port = httpd.server_address[1]
    thread = threading.Thread(target=httpd.serve_forever, daemon=True)
    thread.start()
    return httpd, f"http://127.0.0.1:{port}/prototype.html"


def capture(only: set[str] | None = None) -> tuple[list[dict], tempfile.TemporaryDirectory]:
    httpd, base_url = start_server()
    tmp = tempfile.TemporaryDirectory(prefix="bt-v6-shots-")
    try:
        # Stable review surface: 393x852 logical / 1179x2556 export.
        ctx, obs = sync()
        bsk("emulate", "--context", ctx, "--observation", obs,
            "--width", "393", "--height", "852", "--dpr", "3", "--mobile", "--touch")
        time.sleep(0.5)
        action("navigate", f"{base_url}?export=1&v={int(time.time())}")
        time.sleep(0.8)
        action("evaluate", "btPrototype.theme('深色'); document.documentElement.dataset.theme='dark'; true")

        records: list[dict] = []
        for index, (screen_id, code, group, title, description) in enumerate(SCREENS, 1):
            if only and screen_id not in only:
                continue
            expression = """(() => {
  btPrototype.showScreen(%s);
  const chat = document.querySelector('.chat-content');
  if (chat) chat.scrollTop = chat.scrollHeight;
  else document.querySelector('.app-content')?.scrollTo(0, 0);
  document.querySelectorAll('.sheet-body,.drawer-list,.terminal-output,.files-list').forEach(e => e.scrollTop = 0);
  document.activeElement?.blur?.();
  const d = document.getElementById('device').getBoundingClientRect();
  const sheet = document.querySelector('.sheet')?.getBoundingClientRect();
  return {
    ready: !!document.querySelector('#app .app-header,#app .ios-large-header,#app .startup-content'),
    overflow: document.documentElement.scrollWidth > innerWidth,
    device: {w: d.width, h: d.height},
    sheetOK: !sheet || (sheet.x >= 0 && sheet.right <= innerWidth + 1 && sheet.y >= 0 && sheet.bottom <= innerHeight + 1),
    buttons: document.querySelectorAll('#app button').length
  };
})()""" % json.dumps(screen_id)
            result = action("evaluate", expression)
            metrics = result.get("value") or {}
            time.sleep(0.55 if "search" in screen_id else 0.45)
            target = TEMP / f"{code}_{screen_id}_dark.png"
            action("screenshot", "--out", str(target))
            width, height = png_size(target)
            if (width, height) != (1179, 2556):
                raise RuntimeError(f"{screen_id}: unexpected PNG {width}x{height}")
            if not metrics.get("ready"):
                raise RuntimeError(f"{screen_id}: app did not render")
            records.append({
                "id": screen_id,
                "code": code,
                "group": group,
                "title": title,
                "description": description,
                "theme": "dark",
                "file": f"screens/{code}_{screen_id}_dark.png",
                "metrics": metrics,
            })
            if index % 10 == 0:
                print(f"captured {index}/{len(SCREENS)}", flush=True)
        return records, tmp
    finally:
        httpd.shutdown()
        httpd.server_close()


def build_atlas(records: list[dict]) -> None:
    by_group: dict[str, list[dict]] = {}
    for record in records:
        by_group.setdefault(record["group"], []).append(record)
    nav = "".join(
        f'<a href="#g{i}">{html.escape(group)}</a>'
        for i, group in enumerate(by_group)
    )
    body = ""
    for i, (group, items) in enumerate(by_group.items()):
        body += (
            f'<section id="g{i}"><header class="group-head"><span>{str(i + 1).zfill(2)}</span>'
            f'<h2>{html.escape(group)}</h2></header><div class="grid">'
        )
        for record in items:
            body += (
                f'<article data-theme="dark" data-group="{html.escape(group)}">'
                f'<div class="tile-title"><small>{record["code"]}</small><h3>{html.escape(record["title"])}</h3></div>'
                f'<a class="image-link" href="{record["file"]}" target="_blank">'
                f'<img loading="lazy" src="{record["file"]}" alt="{html.escape(record["title"])}原型图" width="393" height="852"></a>'
                f'<p>{html.escape(record["description"])}</p>'
                f'<div class="links"><a href="prototype.html?screen={record["id"]}">交互预览 ↗</a>'
                f'<a href="{record["file"]}" download>下载 PNG</a></div></article>'
            )
        body += "</div></section>"

    theme_section = (
        '<section id="theme"><header class="group-head"><span>00</span><h2>主题与材质</h2></header><div class="grid">'
        '<article><div class="tile-title"><small>THEME</small><h3>浅色主题</h3></div>'
        '<a class="image-link" href="浅色主题预览.png" target="_blank"><img loading="lazy" src="浅色主题预览.png" alt="浅色主题预览" width="1028" height="1776"></a>'
        '<p>AI 对话、账户、文件、设置、外观、主机管理。浅色表面、深色终端与宝塔绿主操作。</p>'
        '<div class="links"><a href="浅色主题预览.png" target="_blank">查看大图 ↗</a></div></article>'
        '<article><div class="tile-title"><small>MATERIAL</small><h3>材质对比</h3></div>'
        '<a class="image-link" href="材质对比预览.png" target="_blank"><img loading="lazy" src="材质对比预览.png" alt="材质对比预览" width="1192" height="916"></a>'
        '<p>同一 AI 对话的深色玻璃、减少透明效果、增强对比度和浅色玻璃四种状态。</p>'
        '<div class="links"><a href="材质对比预览.png" target="_blank">查看大图 ↗</a></div></article>'
        '</div></section>'
    )
    body = theme_section + body

    page = """<!doctype html><html lang="zh-CN"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>宝塔终端 · 完整原型图集</title>
<style>*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:#f3f5f4;color:#1c2523;font:14px/1.7 -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif}a{color:#12743e;text-decoration:none}.top{background:#fff;border-bottom:1px solid #e1e7e3;padding:48px 5% 32px}.meta{font:12px monospace;color:#6d7871}h1{font-size:36px;letter-spacing:-1px;line-height:1.3;font-weight:500;margin:12px 0}h1 span{color:#168448}p{color:#59635f}.top .actions{display:flex;gap:24px;flex-wrap:wrap;margin-top:24px;font-size:13px}nav{background:#fff;padding:16px 5%;border-bottom:1px solid #e1e7e3;display:flex;gap:24px;flex-wrap:wrap;position:sticky;top:0;z-index:1;font-size:12px}main{max-width:1800px;margin:auto;padding:24px 5% 64px}.group-head{display:flex;gap:16px;align-items:center;margin:40px 0 24px}.group-head span{font:13px monospace;color:#168448}.group-head h2{font-size:22px;font-weight:500;margin:0}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(248px,1fr));gap:32px 24px}article{min-width:0}.tile-title{display:flex;flex-direction:column;gap:4px;min-height:68px}.tile-title small{font:11px monospace;color:#727c77}.tile-title h3{font-size:15px;font-weight:500;margin:0}.image-link{display:block;background:#fff;overflow:hidden;border-radius:16px;border:1px solid #d6dfd9;box-shadow:0 4px 12px #10201904}img{width:100%;height:auto;display:block}article p{min-height:66px;font-size:12px;line-height:1.8;margin:16px 0 8px}.links{display:flex;justify-content:space-between;font-size:12px}footer{border-top:1px solid #dce4df;padding:24px 5%;font-size:11px;color:#727c77}section{scroll-margin-top:80px}@media(max-width:600px){h1{font-size:28px}.top{padding-top:32px}.grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:24px 12px}.tile-title{min-height:72px}.tile-title h3{font-size:13px}article p{display:none}.links{margin-top:12px;flex-direction:column;gap:8px}.image-link{border-radius:8px}nav{gap:12px;font-size:11px}.group-head h2{font-size:18px}}</style>
<header class="top"><div class="meta">BT-Xterm / MOBILE UI · 2026.11 · V6</div>
<h1>AI 对话，<span>长按与左滑管理</span></h1>
<p>__COUNT__ 个页面与状态 · iPhone 16 屏幕 · 1179 × 2556 PNG<br>可操作设计原型，所有远程操作与业务数据均为模拟</p>
<div class="actions"><a href="index.html">交付总览 ↗</a><a href="prototype.html">打开交互原型 ↗</a><a href="功能清单.html">查看 118 项功能清单 ↗</a><a href="主题规范.md">主题与材质规范 ↗</a><a href="核心界面总览.png" target="_blank">核心界面总览</a><a href="完整原型总览.png" target="_blank">完整原型总览</a><a href="iPhone 16 外观预览.png" target="_blank">iPhone 16 外观</a><a href="搜索组件预览.png" target="_blank">搜索组件预览</a><a href="磨砂滚动预览.png" target="_blank">磨砂滚动预览</a><a href="AI 对话配色预览.png" target="_blank">AI 对话配色预览</a><a href="浅色主题预览.png" target="_blank">浅色主题预览</a><a href="材质对比预览.png" target="_blank">材质对比预览</a><a href="优化分析.md">优化分析</a></div></header>
<nav>__NAV__</nav><main>__BODY__</main>
<footer>Design method: unslop by <a href="https://heydamir.design/unslop">Damir Sokolovsky (@heydamir)</a>, CC BY 4.0 · V6 Liquid Glass consistency pass</footer></html>"""
    document_styles = json.loads((BASE / "delivery" / "dark-document-styles.json").read_text(encoding="utf-8"))
    page = page.replace("</style>", document_styles["图集.html"] + "</style>", 1)
    (BASE / "图集.html").write_text(
        page.replace("__NAV__", nav).replace("__BODY__", body).replace("__COUNT__", str(len(records))),
        encoding="utf-8",
    )


def build_posters(records: list[dict]) -> None:
    font_path = "/System/Library/Fonts/Hiragino Sans GB.ttc"
    if not Path(font_path).exists():
        font_path = "/System/Library/Fonts/STHeiti Medium.ttc"
    if not Path(font_path).exists():
        raise RuntimeError("No CJK system font available for posters")

    def font(size: int):
        return ImageFont.truetype(font_path, size)

    bg, ink, muted, green = "#0b0d0f", "#edf1f5", "#89939f", "#61ca8d"

    def poster(items: list[dict], path: str, cols: int = 5, thumb: int = 260,
               title: str = "宝塔终端 V6 · iOS Liquid Glass",
               subtitle: str = "移动端 UI 设计 / 2026.11") -> None:
        margin, gap, label_h, top = 40, 24, 64, 180
        thumb_h = round(thumb * 852 / 393)
        row_h = thumb_h + label_h + 48
        width = margin * 2 + cols * thumb + (cols - 1) * gap
        height = top + math.ceil(len(items) / cols) * row_h + 48
        out = Image.new("RGB", (width, height), bg)
        d = ImageDraw.Draw(out)
        d.rounded_rectangle((margin, 40, margin + 5, 90), 2, fill=green)
        d.text((margin + 20, 36), title, font=font(32), fill=ink)
        d.text((margin + 20, 92), subtitle, font=font(14), fill=muted)
        for i, record in enumerate(items):
            x = margin + (i % cols) * (thumb + gap)
            y = top + (i // cols) * row_h
            d.text((x, y), record["code"] + " / Dark", font=font(11), fill=green)
            d.text((x, y + 22), record["title"], font=font(16), fill=ink)
            im = Image.open(BASE / record["file"]).convert("RGB").resize((thumb, thumb_h), Image.Resampling.LANCZOS)
            mask = Image.new("L", im.size, 0)
            md = ImageDraw.Draw(mask)
            md.rounded_rectangle((0, 0, thumb - 1, thumb_h - 1), radius=14, fill=255)
            out.paste(im, (x, y + label_h), mask)
            d.rounded_rectangle((x, y + label_h, x + thumb - 1, y + label_h + thumb_h - 1), radius=14, outline="#414a54", width=1)
        d.text((margin, out.height - 32), "UI prototype · simulated data / unslop by @heydamir, CC BY 4.0", font=font(11), fill=muted)
        out.save(BASE / path, optimize=True)

    pages = [r for r in records if r["theme"] == "dark"]
    core_ids = ["startup-loading", "conversations", "ai-chat", "conversation-menu",
                "conversation-swipe", "conversation-multi", "conversation-rename",
                "conversation-delete", "conversation-pinned"]
    core = [next(r for r in pages if r["id"] == rid) for rid in core_ids]
    dark_ids = ["files-home", "files-preview", "files-auth", "conversations", "accessibility",
                "ai-home", "ai-chat", "ai-target-changed", "terminal-home", "host-sessions",
                "terminal", "terminal-keyboard", "ai-handoff", "sftp", "settings", "account",
                "reconnect-choice"]
    poster(core, "核心界面总览.png", 3, 260,
           subtitle="启动初始化 / AI 对话 / 手势菜单 / 多选 / 重命名 / 删除确认 / 置顶")
    poster(pages, "完整原型总览.png", 7, 220,
           subtitle=f"{len(pages)} 个黑色页面与关键状态 · 单页 PNG 与交互入口见图集.html")
    poster([r for r in records if r["id"] in dark_ids], "深色界面总览.png", 5, 240,
           title="宝塔终端 V6 · 深色界面", subtitle="17 个主要页面 · 中性灰阶与宝塔绿")
    poster([r for r in records if r["group"] == "搜索交互"], "搜索组件预览.png", 4, 220,
           title="宝塔终端 V6 · 搜索组件", subtitle="AI 对话 / 连接 / 文件 / 设置 · 共享展开方式与底部背景")
    color_ids = ["conversations", "ai-home", "ai-chat", "ai-done", "ai-error"]
    poster([next(r for r in pages if r["id"] == rid) for rid in color_ids], "AI 对话配色预览.png", 5, 220,
           title="宝塔终端 V6 · AI 对话配色", subtitle="石墨灰阅读层次 / 琥珀待确认 / 宝塔绿成功 / 珊瑚红异常")


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--only", default="", help="Comma-separated screen ids for a partial capture")
    parser.add_argument("--skip-posters", action="store_true")
    args = parser.parse_args()
    only = {x.strip() for x in args.only.split(",") if x.strip()} or None
    records, tmp = capture(only)
    try:
        if only:
            for record in records:
                src = TEMP / Path(record["file"]).name
                shutil.copyfile(src, BASE / record["file"])
            print(f"partial capture: {len(records)} screens in {TEMP}")
            return
        for record in records:
            shutil.copyfile(TEMP / Path(record["file"]).name, BASE / record["file"])
        (BASE / "delivery" / "manifest.json").write_text(
            json.dumps([{k: r[k] for k in ("id", "code", "group", "title", "description", "theme", "file")} for r in records],
                       ensure_ascii=False, indent=2), encoding="utf-8")
        checks = [{k: v for k, v in r["metrics"].items() if k in ("buttons", "device", "overflow", "ready", "sheetOK")}
                  | {"id": r["id"], "theme": r["theme"]} for r in records]
        checks = [{"id": c["id"], "theme": c["theme"], "buttons": c["buttons"], "device": c["device"],
                   "overflow": c["overflow"], "ready": c["ready"], "sheetOK": c["sheetOK"]} for c in checks]
        (BASE / "delivery" / "export-checks.json").write_text(json.dumps(checks, ensure_ascii=False, indent=2),
                                                             encoding="utf-8")
        build_atlas(records)
        if not args.skip_posters:
            build_posters(records)
        print(json.dumps({"exported": len(records), "png": "1179x2556",
                          "atlas": "图集.html", "manifest": "delivery/manifest.json"}, ensure_ascii=False))
    finally:
        tmp.cleanup()
        if not only:
            shutil.rmtree(TEMP, ignore_errors=True)


if __name__ == "__main__":
    main()
