#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""微信绑定二维码「常驻链接」服务 —— 让一个链接在 24 小时内随时打开都是活码。

为什么这样做：iLink 接口只接受 bot_type，**服务端有效期无法改**。
  实测（2026-10-12）：二维码服务端寿命 ≈ 180 秒 —— t+119s 仍为 wait，t+179s 已 expired。
  因此续码间隔取 90 秒（小于半个寿命），任何时刻页面上那张码至少还剩 ~90 秒，扫码+确认足够。
所以本服务在后台**自动续码**（默认每 90 秒换一张新码，永远早于过期），
任何人 24 小时内打开这个链接，看到的都是当前有效的二维码，扫码→确认一气做完即可。

端点：
  /            页面：显示当前二维码 + 状态 + 剩余时间，自动刷新
  /qr.png      当前二维码 PNG（带防缓存参数）
  /status.json 当前状态（wait / scaned / confirmed / expired）+ 生成时间 + 年龄
  /health      健康检查

用法：
  ~/.hermes/hermes-agent/venv/bin/python weixin_qr_service.py --port 8799 [--hours 24] [--profile baorui-sales] [--host 0.0.0.0]
确认扫码后：凭据自动落到 --profile 的 weixin/accounts/ 下（不自动改 .env、不重启网关），
页面会提示"已确认"，由管理员完成后续绑定。
"""
import argparse
import asyncio
import base64
import io
import json
import os
import socket
import sys
import threading
import time
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

REPO = os.path.expanduser('~/.hermes/hermes-agent')
sys.path.insert(0, REPO)

STATE = {'status': 'starting', 'qr_png': None, 'fetched_at': 0, 'refreshes': 0,
         'account_id': '', 'user_id': '', 'error': '', 'saved_to': ''}
LOCK = threading.Lock()
CFG = {'profile': 'baorui-sales', 'refresh': 90, 'status_every': 5}


def _fetch_once(profile: str) -> None:
    """拉一张新码并落 PNG（同步执行，内部跑 asyncio）。"""
    from gateway.platforms.weixin import _fetch_qr, _new_session
    import qrcode

    async def go():
        async with _new_session() as s:
            token, url = await _fetch_qr(s, '3')
            return token, url

    token, url = asyncio.run(go())
    qr = qrcode.QRCode(box_size=14, border=4)
    qr.add_data(url or token)
    qr.make(fit=True)
    img = qr.make_image(fill_color='black', back_color='white')
    buf = io.BytesIO()
    try:                       # PIL 后端接受 format=
        img.save(buf, format='PNG')
    except TypeError:          # PyPNG 后端不接受（venv 无 PIL 时会走这支）
        img.save(buf)
    with LOCK:
        STATE.update({'status': 'wait', 'qr_png': buf.getvalue(), 'fetched_at': time.time(),
                      'refreshes': STATE['refreshes'] + 1, 'error': '', 'token': token,
                      'account_id': '', 'user_id': '', 'saved_to': ''})


def _poll_status() -> None:
    """查当前码的扫码状态；confirmed 时把凭据落到目标 profile。"""
    from gateway.platforms.weixin import _api_get, _new_session, EP_GET_QR_STATUS, ILINK_BASE_URL
    with LOCK:
        token = STATE.get('token') or ''
    if not token:
        return

    async def go():
        async with _new_session() as s:
            return await _api_get(s, base_url=ILINK_BASE_URL,
                                  endpoint='%s?qrcode=%s' % (EP_GET_QR_STATUS, token),
                                  timeout_ms=35000)

    try:
        r = asyncio.run(go())
    except Exception as e:  # 网络瞬断不致命
        with LOCK:
            STATE['error'] = '%s: %s' % (type(e).__name__, e)
        return
    st = str(r.get('status') or 'wait')
    with LOCK:
        STATE['status'] = st
        STATE['error'] = ''
    if st in ('scaned', 'scaned_but_redirect', 'confirmed'):
        if st == 'confirmed':
            acct = str(r.get('ilink_bot_id') or '')
            uid = str(r.get('ilink_user_id') or '')
            try:
                from gateway.platforms.weixin import save_weixin_account
                home = os.path.expanduser('~/.hermes/profiles/%s' % CFG['profile']) \
                    if CFG['profile'] else os.path.expanduser('~/.hermes')
                save_weixin_account(home, account_id=acct, token=str(r.get('bot_token') or ''),
                                    user_id=uid, base_url=str(r.get('baseurl') or ILINK_BASE_URL))
                saved = home
            except Exception as e:
                saved = '保存失败: %s' % e
            with LOCK:
                STATE.update({'account_id': acct, 'user_id': uid, 'saved_to': saved})


def worker(stop_at: float) -> None:
    while time.time() < stop_at:
        with LOCK:
            age = time.time() - STATE['fetched_at']
            need_refresh = (STATE['qr_png'] is None) or age > CFG['refresh'] or \
                           STATE['status'] in ('expired', 'confirmed')
        try:
            if need_refresh:
                _fetch_once(CFG['profile'])
            if STATE['status'] not in ('confirmed',):
                _poll_status()
        except Exception as e:
            with LOCK:
                STATE['error'] = '%s: %s' % (type(e).__name__, e)
        # 已确认则停下不再换码
        with LOCK:
            if STATE['status'] == 'confirmed':
                time.sleep(5)
                continue
        time.sleep(CFG['status_every'])


PAGE = """<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>微信绑定二维码（常驻）</title><style>
body{margin:0;background:#f5f7fa;font:15px/1.7 -apple-system,"PingFang SC",sans-serif;color:#1f2328;
 display:flex;align-items:center;justify-content:center;min-height:100vh}
.card{background:#fff;border-radius:14px;padding:22px;box-shadow:0 2px 12px rgba(16,24,40,.1);text-align:center;max-width:460px}
h1{font-size:19px;color:#0F3A8A;margin:0 0 4px}
.sub{color:#5f6368;font-size:13px;margin-bottom:14px}
img{width:320px;height:320px;image-rendering:pixelated}
.st{margin-top:12px;font-weight:700;font-size:15px}
.ok{color:#1a7f45}.wait{color:#1852B6}.warn{color:#B45309}
.meta{color:#8a94a6;font-size:12px;margin-top:6px}
.tip{background:#F2F6FD;border-radius:9px;padding:10px 12px;font-size:12.5px;color:#475467;margin-top:14px;text-align:left}
</style></head><body><div class="card">
<h1>扫描绑定微信</h1>
<div class="sub">用微信「扫一扫」扫下方二维码，扫完在手机上点确认</div>
<img id="qr" src="/qr.png" alt="二维码">
<div class="st" id="st">加载中…</div>
<div class="meta" id="meta"></div>
<div class="tip">本页二维码会**自动续码**：随时打开都是当前有效的码，不用等新的。<br>
只能用微信扫，浏览器打开无效。</div>
</div><script>
function tick(){
 fetch('/status.json?ts='+Date.now()).then(r=>r.json()).then(d=>{
  const st=document.getElementById('st'), meta=document.getElementById('meta');
  const map={wait:['等待扫码','wait'],scaned:['已扫码，请在手机上点确认','ok'],
   scaned_but_redirect:['已扫码，请在手机上点确认','ok'],confirmed:['✅ 已确认，绑定成功','ok'],
   expired:['该码已过期，正在自动换新码…','warn'],starting:['正在生成二维码…','wait']};
  const m=map[d.status]||[d.status,'wait']; st.textContent=m[0]; st.className='st '+m[1];
  meta.textContent='当前码生成于 '+d.fetched_time+'（已 '+d.age_s+' 秒，第 '+d.refreshes+' 次续码）';
  if(d.status==='confirmed'){ location.reload(); }
 }).catch(()=>{});
}
setInterval(function(){ document.getElementById('qr').src='/qr.png?ts='+Date.now(); }, 45000);
setInterval(tick, 3000); tick();
</script></body></html>"""


class H(BaseHTTPRequestHandler):
    def log_message(self, *a):
        pass

    def _send(self, code, body, ctype):
        self.send_response(code)
        self.send_header('Content-Type', ctype)
        self.send_header('Cache-Control', 'no-store')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        p = self.path.split('?')[0]
        with LOCK:
            st, png, ts, n = STATE['status'], STATE['qr_png'], STATE['fetched_at'], STATE['refreshes']
        if p == '/qr.png':
            if not png:
                return self._send(503, b'not ready', 'text/plain')
            return self._send(200, png, 'image/png')
        if p == '/status.json':
            return self._send(200, json.dumps({
                'status': st, 'age_s': int(time.time() - ts) if ts else None,
                'fetched_time': datetime.fromtimestamp(ts).strftime('%H:%M:%S') if ts else None,
                'refreshes': n, 'account_id': STATE['account_id'][:14] + '…' if STATE['account_id'] else '',
                'saved_to': STATE['saved_to'], 'error': STATE['error'],
            }, ensure_ascii=False).encode(), 'application/json; charset=utf-8')
        if p == '/health':
            return self._send(200, b'ok', 'text/plain')
        return self._send(200, PAGE.encode('utf-8'), 'text/html; charset=utf-8')


def lan_ips():
    out = set()
    try:
        for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
            ip = info[4][0]
            if not ip.startswith('127.'):
                out.add(ip)
    except Exception:
        pass
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(('8.8.8.8', 80))
        out.add(s.getsockname()[0])
        s.close()
    except Exception:
        pass
    return sorted(out)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--port', type=int, default=8799)
    ap.add_argument('--host', default='127.0.0.1')
    ap.add_argument('--hours', type=float, default=24)
    ap.add_argument('--profile', default='baorui-sales')
    ap.add_argument('--refresh', type=int, default=90)
    a = ap.parse_args()
    CFG.update({'profile': a.profile, 'refresh': a.refresh})
    stop_at = time.time() + a.hours * 3600
    threading.Thread(target=worker, args=(stop_at,), daemon=True).start()
    srv = ThreadingHTTPServer((a.host, a.port), H)
    print('二维码常驻服务已启动：')
    print('   本机  http://127.0.0.1:%d/' % a.port)
    for ip in lan_ips():
        print('   局域网 http://%s:%d/' % (ip, a.port))
    print('   续码间隔 %ds ｜ 运行上限 %.0f 小时 ｜ 确认后凭据落到 profile=%s' % (a.refresh, a.hours, a.profile))
    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        print('服务退出（%.1f 小时）' % ((time.time() - (stop_at - a.hours * 3600)) / 3600.0))


if __name__ == '__main__':
    main()
