#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""zhixin_anchor.py — 至信链（腾讯 ZhiXinChain）可信存证 transport for WDY records.

把一条 WDY 记录的哈希锚定到至信链（国内司法级区块链存证），并把返回的
evId / txId / blockHeight / txTime 回写进 manifest.anchors.zhixin，然后用
Ed25519 重新联签 manifest。

═══════════════════════════════════════════════════════════════════════
规格来源（2026-09-11 实测核对，非推测）
═══════════════════════════════════════════════════════════════════════
[1] 鉴权：https://console.zxchain.qq.com/docs?docpath=doc/content/API文档/接口接入鉴权.html
    API 域名 https://open.zxchain.qq.com；header 需 Secret-Id / Signature /
    Signature-Time / Nonce / Cloud-trace-id；签名须由官方 SDK 生成。
[2] 签名算法（官方 Go SDK 源码 nft_wallet_sdk/hdw.go:263 GenerateApiSign）：
      signTime = 毫秒时间戳；nonce = 随机整数
      signStr  = secretId + "&" + secretKey + "&" + signTime + "&" + nonce
      Signature = hex( HMAC-SM3( key = secretKey, msg = signStr ) )
    → 纯 Python 可复现，本文件内置 SM3 + HMAC-SM3（零依赖），已与官方
      本地服务 /generateApiSign 逐字节比对一致。
[3] 可信存证接口：.../API文档/可信存证相关接口/可信存证接口.html
      POST /api/v1/ev/save   body: publicKey, hash, sign, extendInfo
        - hash = 文件内容的 **SM3**（不是 SHA-256；见 SDK apidemo/ev_test.go
          调 nft_wallet_sdk.SM3Hash(data)）
        - sign = SM2(SM3withSM2) 私钥签名，签名内容 = publicKey_hash_extendInfo
          （下划线按该顺序拼接；SDK GetSignData() 实现）
        - 响应 data: evId, txId, blockHeight, txTime
      GET  /api/v1/ev/query  params: evId | hash | txId（至少一个）
        - 响应 data: evId, txId, blockHeight, txTime, extendInfo
      POST /api/v1/ev/cert   body: evId        → data: {status}
      GET  /api/v1/ev/cert   params: evId      → data: {status, certUrl}
      人工证书页（SDK GetEvCert 同款）：https://zxscan.qq.com/cert/ev?evId=<evId>
[4] 本地服务 SDK（Go 封装的 HttpService，离线，官方提供 windows/linux/mac 可执行文件）：
      http://127.0.0.1:30505/{generateApiSign,createMnemonic,deriveKeyPair,
        priKey2PubKey,pubKey2Address,priKey2Address,signByPriKey,
        verifyByPubKey,sm3Hash,uploadToCos}
      文档写 signData{signature,signatureTime,nonce}，实际返回 PascalCase
      {"Signature","SignatureTime","Nonce"} —— 本模块对两种大小写都兼容（实测）。
[5] 开通前置（agent 代办不了，必须用户线下）：注册至信链控制台账号 → 实名认证
      → 新建链账户 → 开通存证服务（官方赠试用套餐）。secretId/secretKey 在
      控制台「账号信息-基本信息」；存证用的 SM2 公私钥用 deriveKeyPair 派生
      （同一助记词+同一 index 可重复派生出同一对密钥——务必离线保存助记词）。

═══════════════════════════════════════════════════════════════════════
用法
═══════════════════════════════════════════════════════════════════════
  0) 自检（无需任何凭据，验证本机 crypto 与官方服务一致）：
       python zhixin_anchor.py --self-test
  1) 配置：复制 zhixin.json.example → zhixin.json（勿入 git），填 secretId/
     secretKey/公私钥路径。
  2) 锚定并回写：
       python zhixin_anchor.py --hash <sha256hex> --record WDY-2026-0003 \
              --file <原件路径> --manifest <manifest.json> --key <ed25519私钥>
     可加 --dry-run 只打印将发出的请求；--mode mock 走假数据跑通全流程。
  3) 查询/取证书：
       python zhixin_anchor.py --query-ev <evId>
       python zhixin_anchor.py --cert <evId>

退出码：0 成功；1 失败；2 参数/配置错误；3 物料缺失（凭据未就绪，pending）。
"""

import argparse
import base64
import hashlib
import hmac
import json
import os
import random
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from datetime import datetime, timezone
from pathlib import Path

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
from wdy_ed25519 import canonical_bytes, ed25519_sign  # noqa: E402

CONFIG_CANDIDATES = [HERE / "zhixin.json", Path.cwd() / "zhixin.json"]
CONFIG_ENV = "WDY_ZHIXIN_CONFIG"
DEFAULT_ENDPOINT = "https://open.zxchain.qq.com"
DEFAULT_LOCAL_SERVICE = "http://127.0.0.1:30505"
EXTEND_MAX = 1024

# ─────────────────────────── SM3 (GB/T 32905) ───────────────────────────
_MASK = 0xFFFFFFFF
_IV = [0x7380166F, 0x4914B2B9, 0x172442D7, 0xDA8A0600,
       0xA96F30BC, 0x163138AA, 0xE38DEE4D, 0xB0FB0E4E]


def _rotl(x, n):
    n %= 32
    return ((x << n) | (x >> (32 - n))) & _MASK if n else x & _MASK


def _p0(x):
    return (x ^ _rotl(x, 9) ^ _rotl(x, 17)) & _MASK


def _p1(x):
    return (x ^ _rotl(x, 15) ^ _rotl(x, 23)) & _MASK


def _ff(j, x, y, z):
    return (x ^ y ^ z) if j < 16 else ((x & y) | (x & z) | (y & z))


def _gg(j, x, y, z):
    return (x ^ y ^ z) if j < 16 else ((x & y) | ((~x & _MASK) & z))


def _cf(v, block):
    w = [int.from_bytes(block[i * 4:i * 4 + 4], "big") for i in range(16)]
    for j in range(16, 68):
        w.append((_p1(w[j - 16] ^ w[j - 9] ^ _rotl(w[j - 3], 15))
                  ^ _rotl(w[j - 13], 7) ^ w[j - 6]) & _MASK)
    w1 = [(w[j] ^ w[j + 4]) & _MASK for j in range(64)]

    a, b, c, d, e, f, g, h = v
    for j in range(64):
        t = 0x79CC4519 if j < 16 else 0x7A879D8A
        ss1 = _rotl(((_rotl(a, 12) + e + _rotl(t, j)) & _MASK), 7)
        ss2 = ss1 ^ _rotl(a, 12)
        tt1 = (_ff(j, a, b, c) + d + ss2 + w1[j]) & _MASK
        tt2 = (_gg(j, e, f, g) + h + ss1 + w[j]) & _MASK
        d = c
        c = _rotl(b, 9)
        b = a
        a = tt1
        h = g
        g = _rotl(f, 19)
        f = e
        e = _p0(tt2)
    return [x ^ y for x, y in zip(v, (a, b, c, d, e, f, g, h))]


def sm3(data: bytes) -> bytes:
    """Pure-Python SM3 (Chinese national hash standard). Zero dependencies."""
    if isinstance(data, str):
        data = data.encode("utf-8")
    v = list(_IV)
    msg = bytearray(data)
    bitlen = len(msg) * 8
    msg.append(0x80)
    while len(msg) % 64 != 56:
        msg.append(0)
    msg += bitlen.to_bytes(8, "big")
    for off in range(0, len(msg), 64):
        v = _cf(v, bytes(msg[off:off + 64]))
    return b"".join(x.to_bytes(4, "big") for x in v)


def sm3_hex(data: bytes) -> str:
    return sm3(data).hex()


def hmac_sm3(key: bytes, msg: bytes) -> bytes:
    """HMAC instantiated with SM3 (block size 64) — matches official SDK hds.SM3HMAC."""
    if isinstance(key, str):
        key = key.encode("utf-8")
    if isinstance(msg, str):
        msg = msg.encode("utf-8")
    if len(key) > 64:
        key = sm3(key)
    key = key + b"\x00" * (64 - len(key))
    inner = bytes(b ^ 0x36 for b in key) + msg
    outer = bytes(b ^ 0x5C for b in key) + sm3(inner)
    return sm3(outer)


# ─────────────────────────── 鉴权（官方算法，已在源码核对） ───────────────────────────
def generate_api_sign(secret_id, secret_key, now_ms=None, nonce=None):
    """Return dict with the exact header values the official SDK produces."""
    sign_time = str(now_ms if now_ms is not None else int(time.time() * 1000))
    if nonce is None:
        nonce = random.SystemRandom().randint(0, 999999)
    sign_str = f"{secret_id}&{secret_key}&{sign_time}&{nonce}"
    signature = hmac_sm3(secret_key.encode("utf-8"), sign_str.encode("utf-8")).hex()
    return {"Signature": signature, "SignatureTime": sign_time, "Nonce": int(nonce)}


def auth_headers(cfg):
    sd = generate_api_sign(cfg["secretId"], cfg["secretKey"])
    return {
        "Secret-Id": cfg["secretId"],
        "Signature": sd["Signature"],
        "Signature-Time": sd["SignatureTime"],
        "Nonce": str(sd["Nonce"]),
        "Cloud-Trace-Id": str(uuid.uuid4()),
        "Content-Type": "application/json;charset=utf-8",
    }


# ─────────────────────────── 本地服务（SM2 / 派生 / SM3） ───────────────────────────
def _pick(d, *names):
    """Case-insensitive first-hit lookup: 文档写 signature，实际返回 Signature。"""
    lower = {k.lower(): v for k, v in d.items()}
    for n in names:
        if n.lower() in lower:
            return lower[n.lower()]
    return None


def local_service(cfg, path, payload=None, timeout=15):
    base = cfg.get("local_service", DEFAULT_LOCAL_SERVICE).rstrip("/")
    url = f"{base}/{path.lstrip('/')}"
    data = json.dumps(payload).encode("utf-8") if payload is not None else None
    req = urllib.request.Request(url, data=data, method="POST",
                                headers={"Content-Type": "application/json;charset=utf-8"})
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            body = json.loads(r.read().decode("utf-8"))
    except (urllib.error.URLError, OSError, json.JSONDecodeError) as e:
        raise RuntimeError(
            f"本地服务不可用（{base}）：{e}\n"
            "  需要下载并启动官方 nft_wallet_service：\n"
            "  windows https://zxchain-1258344699.cos.ap-guangzhou.myqcloud.com/static_dir/sdk/wallet_service.exe\n"
            "  (文档: console.zxchain.qq.com → SDK文档 → 本地服务SDK文档)") from e
    err = _pick(body, "err")
    if err:
        raise RuntimeError(f"本地服务返回错误：{err}")
    return body


def local_api_sign(cfg, secret_id, secret_key):
    """/generateApiSign 的封装。

    实测：官方服务返回 {"err":"","signData":{"Signature":…,"SignatureTime":…,"Nonce":…}}
    （文档写的是小写 signature/signatureTime/nonce）—— 这里把嵌套与大小写都吃掉。
    """
    body = local_service(cfg, "generateApiSign",
                         {"secretId": secret_id, "secretKey": secret_key})
    sd = body.get("signData") or body
    sig = _pick(sd, "Signature")
    t = _pick(sd, "SignatureTime")
    n = _pick(sd, "Nonce")
    if not (sig and t and n is not None):
        raise RuntimeError(f"本地服务返回结构未识别：{body}")
    return {"Signature": sig, "SignatureTime": str(t), "Nonce": int(n)}


def sm2_sign(cfg, pri_key_pem, message):
    """SM2 签名走官方本地服务（算法未公开文档化，不自行实现以免不一致）。"""
    body = local_service(cfg, "signByPriKey", {"priKey": pri_key_pem, "data": message})
    sig = _pick(body, "signedData", "signed_data")
    if not sig:
        raise RuntimeError(f"本地服务未返回签名：{body}")
    return sig


def sm2_verify(cfg, pub_key_pem, message, signed_data):
    body = local_service(cfg, "verifyByPubKey",
                         {"pubKey": pub_key_pem, "data": message, "signedData": signed_data})
    val = _pick(body, "isValid", "is_valid")
    return str(val).lower() == "true"


def service_sm3_hex(cfg, data: bytes) -> str:
    body = local_service(cfg, "sm3Hash", {"data": base64.b64encode(data).decode()})
    return _pick(body, "digest")


# ─────────────────────────── HTTP（open.zxchain.qq.com） ───────────────────────────
def _http(cfg, method, path, body=None, params=None, timeout=20):
    endpoint = cfg.get("endpoint", DEFAULT_ENDPOINT).rstrip("/")
    url = endpoint + path
    if params:
        url += "?" + urllib.parse.urlencode(params)
    data = json.dumps(body).encode("utf-8") if body is not None else None
    req = urllib.request.Request(url, data=data, method=method, headers=auth_headers(cfg))
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            return json.loads(r.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        raw = e.read().decode("utf-8", "replace")
        try:
            return json.loads(raw)
        except json.JSONDecodeError:
            return {"retCode": e.code, "retMsg": raw[:400]}


def api_ev_save(cfg, public_key_pem, pri_key_pem, target_hash_hex, extend_info):
    sign_data = f"{public_key_pem}_{target_hash_hex}_{extend_info}"
    if cfg.get("mode") == "mock":
        return {"retCode": 0, "retMsg": "OK",
                "data": {"evId": "MOCK-EV-" + uuid.uuid4().hex[:12].upper(),
                         "txId": "0x" + uuid.uuid4().hex,
                         "blockHeight": "12345678",
                         "txTime": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
                         "_sign_content": sign_data, "_sign": "<mock-sm2-signature>"}}
    sign = sm2_sign(cfg, pri_key_pem, sign_data)
    payload = {"publicKey": public_key_pem, "hash": target_hash_hex,
               "sign": sign, "extendInfo": extend_info}
    return _http(cfg, "POST", "/api/v1/ev/save", payload)


def api_ev_query(cfg, ev_id=None, hash_hex=None, tx_id=None):
    params = {}
    if ev_id:
        params["evId"] = ev_id
    if hash_hex:
        params["hash"] = hash_hex
    if tx_id:
        params["txId"] = tx_id
    if not params:
        raise ValueError("ev/query 需要 evId / hash / txId 至少一个")
    if cfg.get("mode") == "mock":
        return {"retCode": 0, "retMsg": "OK",
                "data": {"evId": ev_id or "MOCK-EV", "txId": tx_id or "0xMOCK",
                         "blockHeight": "12345678", "txTime": "2026-09-11 00:00:00",
                         "extendInfo": ""}}
    return _http(cfg, "GET", "/api/v1/ev/query", params=params)


def api_ev_cert(cfg, ev_id, create=False):
    if cfg.get("mode") == "mock":
        return {"retCode": 0, "retMsg": "OK", "data": {"status": 1 if create else 2,
                                                       "certUrl": f"https://zxscan.qq.com/cert/ev?evId={ev_id}"}}
    if create:
        return _http(cfg, "POST", "/api/v1/ev/cert", {"evId": ev_id})
    return _http(cfg, "GET", "/api/v1/ev/cert", params={"evId": ev_id})


def cert_page_url(ev_id):
    """SDK GetEvCert 同款人工可读证书页。"""
    return f"https://zxscan.qq.com/cert/ev?evId={urllib.parse.quote(str(ev_id))}"


# ─────────────────────────── 配置 ───────────────────────────
def load_config(required=True, explicit=None, strict_creds=True):
    candidates = []
    if explicit:
        candidates.append(Path(explicit))
    env = os.environ.get(CONFIG_ENV)
    if env:
        candidates.append(Path(env))
    candidates += CONFIG_CANDIDATES
    for p in candidates:
        if p.exists():
            try:
                cfg = json.loads(p.read_text(encoding="utf-8"))
            except json.JSONDecodeError as e:
                raise SystemExit(f"配置不是合法 JSON：{p} — {e}")
            missing = [k for k in ("secretId", "secretKey") if not cfg.get(k)]
            if missing:
                raise SystemExit(f"配置缺少字段 {missing}：{p}")
            # 占位符/非 ASCII 检测：secretId/secretKey 必须是控制台给出的 ASCII 串，
            # 否则会在发请求时才报 latin-1 编码错（难排查），这里提前拦住。
            for k in ("secretId", "secretKey"):
                v = str(cfg[k])
                if strict_creds and (v.startswith("<") or v.endswith(">") or any(ord(ch) > 127 for ch in v)):
                    raise SystemExit(
                        f"{p} 里的 {k} 还是占位符/含中文：{v!r}\n"
                        "  请到至信链控制台 → 右上角用户名 → 账号信息，复制真实的 secretId/secretKey。")
            cfg.setdefault("endpoint", DEFAULT_ENDPOINT)
            cfg.setdefault("local_service", DEFAULT_LOCAL_SERVICE)
            cfg.setdefault("mode", "live")
            cfg["_path"] = str(p.resolve())
            return cfg
    if required:
        raise SystemExit(
            "未找到 zhixin.json（复制 zhixin.json.example 后填入控制台凭据）。查找顺序：\n"
            f"  --config 指定 > 环境变量 {CONFIG_ENV} > {CONFIG_CANDIDATES[0]} > {CONFIG_CANDIDATES[1]}")
    return None


def _resolve(cfg, p):
    """相对路径按配置文件所在目录解析（便于把凭据放在非部署目录里）。"""
    q = Path(p)
    if q.is_absolute():
        return q
    base = Path(cfg.get("_path", HERE / "zhixin.json")).parent
    return (base / q) if (base / q).exists() else q


def _clean_pem(s):
    """去 BOM（记事本导出常见）、统一换行、去首尾空白。

    实测：至信链密钥生成工具导出的 publicKey.txt/privateKey.txt 首字符是 UTF-8 BOM
    (\ufeff)，直接送本地服务会报 "invalid public key PEM"。
    """
    if isinstance(s, bytes):
        s = s.decode("utf-8-sig", "replace")
    return s.replace("\ufeff", "").replace("\r\n", "\n").strip()


def _read_key(cfg, kind):
    """kind: 'pri' | 'pub' —— 优先配置里的 inline，其次路径。"""
    inline = cfg.get(f"{kind}_key_pem") or cfg.get(f"{kind}Key")
    if inline:
        return _clean_pem(inline)
    p = cfg.get(f"{kind}_key_path")
    if p and _resolve(cfg, p).exists():
        return _clean_pem(_resolve(cfg, p).read_bytes())
    f = HERE / ("zhixin-pri.pem" if kind == "pri" else "zhixin-pub.pem")
    if f.exists():
        return _clean_pem(f.read_bytes())
    raise RuntimeError(
        f"缺少 {kind} 密钥。用本地服务派生（助记词务必离线保存）后填进 zhixin.json：\n"
        "  curl -X POST http://127.0.0.1:30505/deriveKeyPair "
        "-H 'Content-Type: application/json' -d '{\"mnemonic\":\"<12词>\",\"index\":0}'")


# ─────────────────────────── 锚定主流程 ───────────────────────────
def anchor(sha256_hex, record_id, file_path=None, manifest_path=None,
           target="auto", extra_note=None, cfg=None, dry_run=False,
           wait=True, get_cert=False):
    """返回 anchor 结果 dict。target: file|manifest|sha256hex|auto"""
    if not (isinstance(sha256_hex, str) and len(sha256_hex) == 64
            and all(c in "0123456789abcdef" for c in sha256_hex.lower())):
        return {"ok": False, "error": "invalid sha256 (expect 64 lowercase hex)"}
    sha256_hex = sha256_hex.lower()
    cfg = cfg or load_config()

    # 1) 选锚定对象并算 SM3（上链的 hash 字段是 SM3）
    if target == "auto":
        target = "file" if file_path else ("manifest" if manifest_path else "sha256hex")
    if target == "file":
        if not file_path or not Path(file_path).exists():
            return {"ok": False, "error": f"锚定对象为文件但找不到：{file_path}"}
        raw = Path(file_path).read_bytes()
        if hashlib.sha256(raw).hexdigest() != sha256_hex:
            return {"ok": False, "error": "传参 sha256 与 --file 实际内容不符（先核对再签）"}
        chain_hash = sm3_hex(raw)
    elif target == "manifest":
        if not manifest_path or not Path(manifest_path).exists():
            return {"ok": False, "error": f"锚定对象为 manifest 但找不到：{manifest_path}"}
        chain_hash = sm3_hex(Path(manifest_path).read_bytes())
    else:  # sha256hex：无原件时锚定“哈希串本身”，语义较弱但可复算
        chain_hash = sm3_hex(sha256_hex.encode("ascii"))

    # 2) extendInfo：可检索信息（≤1024），必须包含 record_id 与 sha256 以便对照
    ext = {"wdy_record_id": record_id, "sha256": sha256_hex, "target": target,
           "scheme": "WDY-Certified-v0.1", "signer": "wdy.org"}
    if extra_note:
        ext["note"] = extra_note
    extend_info = json.dumps(ext, ensure_ascii=False, separators=(",", ":"))
    if len(extend_info.encode("utf-8")) > EXTEND_MAX:
        ext.pop("note", None)
        extend_info = json.dumps(ext, ensure_ascii=False, separators=(",", ":"))
    if len(extend_info.encode("utf-8")) > EXTEND_MAX:
        return {"ok": False, "error": f"extendInfo 超 1024 字节：{len(extend_info)}"}

    if dry_run:
        pri = _read_key(cfg, "pri") if cfg.get("mode") != "mock" else "<mock>"
        return {"ok": True, "dry_run": True, "chain": "zhixin", "endpoint": cfg["endpoint"],
                "request": {"path": "/api/v1/ev/save", "method": "POST",
                            "hash_sm3": chain_hash, "extendInfo": extend_info,
                            "sign_content": f"<publicKey>_{chain_hash}_{extend_info}",
                            "headers": sorted(auth_headers(cfg).keys())}}

    # 3) 打接口
    try:
        if cfg.get("mode") == "mock":
            resp = api_ev_save(cfg, "<mock-pub>", "<mock-pri>", chain_hash, extend_info)
        else:
            resp = api_ev_save(cfg, _read_key(cfg, "pub"), _read_key(cfg, "pri"),
                               chain_hash, extend_info)
    except RuntimeError as e:
        return {"ok": False, "error": str(e), "pending": True}
    if resp.get("retCode") != 0:
        return {"ok": False, "chain": "zhixin", "retCode": resp.get("retCode"),
                "error": resp.get("retMsg"), "tip": "见文档错误码表（1400xx 鉴权 / 1600x 存证）"}
    d = resp.get("data") or {}
    res = {"ok": True, "chain": "zhixin", "type": "zhixin",
           "record_id": record_id, "sha256": sha256_hex,
           "chain_hash_sm3": chain_hash, "anchor_target": target,
           "evId": d.get("evId"), "txId": d.get("txId"),
           "blockHeight": d.get("blockHeight"), "txTime": d.get("txTime"),
           "extendInfo": extend_info,
           "cert_url": cert_page_url(d.get("evId")),
           "cert_api": "GET /api/v1/ev/cert?evId=<evId>",
           "anchored_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")}

    # 存证是异步入链：轮询确认最终块高/上链时间（官方 SDK demo 同款做法）
    if wait and res.get("evId"):
        print("等待入链确认（ev/query 轮询，会校验是否命中本记录）…")
        w = wait_for_chain(cfg, res["evId"], extend_info=extend_info)
        if w.get("ok"):
            qd = w["data"]
            res["txId"] = qd.get("txId") or res["txId"]
            res["blockHeight"] = qd.get("blockHeight") or res["blockHeight"]
            res["txTime"] = qd.get("txTime") or res["txTime"]
            res["chain_confirmed"] = True
        else:
            res["chain_confirmed"] = False
            res["chain_warning"] = w.get("error")
            res["chain_note"] = ("save 已返回 evId/txId/blockHeight/txTime，可据此公示；"
                                 "query 未命中不代表未上链（该接口实测不过滤条件，见 wait_for_chain 注释）")

    if get_cert and res.get("evId"):
        res["cert_url"] = fetch_cert(cfg, res["evId"])
        res["cert_fetched"] = True
    return res


def wait_for_chain(cfg, ev_id, extend_info=None, attempts=10, interval=3.0, verbose=True):
    """轮询 ev/query 确认上链。

    ⚠ 2026-09-11 实测重大陷阱：至信链 ev/query **不按条件过滤**——精确 evId、精确 txId、
    加空格、大写，全部返回同一条与查询条件无关的记录（别人的，extendInfo 是 curl-test-*），
    且 retCode=0。因此**绝不能用 retCode=0 当作"我这条已上链"**，必须校验返回的
    evId（以及 extendInfo）与本次提交完全一致；不一致一律视为"查不到/不可信"，
    以 ev/save 的同步响应为准。

    官方 SDK apidemo/ev_test.go 的 TestEv 是 save 后每 3 秒 query 一次——在过滤正常时有效。
    """
    last = None
    for i in range(1, attempts + 1):
        r = api_ev_query(cfg, ev_id=ev_id)
        last = r
        d = r.get("data") or {}
        if r.get("retCode") == 0 and d.get("evId") == ev_id:
            if extend_info and d.get("extendInfo") and d["extendInfo"] != extend_info:
                if verbose:
                    print(f"  第 {i} 次查询：evId 命中但 extendInfo 不符，视为不可信，继续等")
                time.sleep(interval)
                continue
            if verbose:
                print(f"  第 {i} 次查询：命中本记录 blockHeight={d.get('blockHeight')} txTime={d.get('txTime')}")
            return {"ok": True, "data": d, "matched": True}
        if verbose:
            got = d.get("evId") if d else None
            print(f"  第 {i} 次查询：retCode={r.get('retCode')} 返回evId={got}（{'不是本记录，接口未过滤' if got else '无数据'}）")
        time.sleep(interval)
    return {"ok": False, "last": last, "matched": False,
            "error": "ev/query 未能命中本记录（该接口实测不过滤条件）；以 save 响应为准"}


def fetch_cert(cfg, ev_id):
    """创建并查询存证证书，返回 certUrl（失败不致命，返回 None）。"""
    try:
        api_ev_cert(cfg, ev_id, create=True)
    except Exception as e:
        print(f"  证书任务创建异常（可稍后重试）：{e}")
    try:
        r = api_ev_cert(cfg, ev_id)
        return (r.get("data") or {}).get("certUrl") or cert_page_url(ev_id)
    except Exception:
        return cert_page_url(ev_id)


def merge_into_manifest(manifest_path, anchor_result, key_path, key_id="2026-rot1",
                        out_path=None):
    """把锚定结果写进 manifest.anchors.zhixin 并用 Ed25519 重新联签。

    注意：重新签名会更新 signature.created_at（内容变更即重新签，符合
    “记录不删、错误加注更正、字段只加不改名”的规则）。
    """
    p = Path(manifest_path)
    m = json.loads(p.read_text(encoding="utf-8"))
    if m.get("record_id") != anchor_result.get("record_id"):
        raise SystemExit(f"record_id 不一致：manifest={m.get('record_id')} "
                         f"anchor={anchor_result.get('record_id')}")
    m.pop("signature", None)
    anchors = m.setdefault("anchors", {})
    anchors["zhixin"] = {
        "type": "zhixin",
        "chain": "至信链（腾讯，长安链底层）",
        "api_endpoint": "https://open.zxchain.qq.com/api/v1/ev/save",
        "anchor_target": anchor_result.get("anchor_target"),
        "hash_sm3": anchor_result.get("chain_hash_sm3"),
        "ev_id": anchor_result.get("evId"),
        "tx_id": anchor_result.get("txId"),
        "block_height": anchor_result.get("blockHeight"),
        "tx_time": anchor_result.get("txTime"),
        "extend_info": anchor_result.get("extendInfo"),
        "cert_url": anchor_result.get("cert_url"),
        "anchored_at": anchor_result.get("anchored_at"),
    }
    seed = Path(key_path).read_bytes()
    if len(seed) == 64:  # hex seed 兼容
        seed = bytes.fromhex(seed.decode().strip())
    sig = ed25519_sign(seed, canonical_bytes(m))
    m["signature"] = {"alg": "Ed25519", "key_id": key_id,
                      "created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
                      "value": base64.b64encode(sig).decode()}
    out = Path(out_path) if out_path else p
    out.write_text(json.dumps(m, ensure_ascii=False, indent=2), encoding="utf-8")
    return str(out)


# ─────────────────────────── 自检 ───────────────────────────
def keygen(cfg, out_dir=None, index=0, force=False):
    """用官方本地服务生成 SM2 密钥对（控制台「新建链账户/申请凭证」要的 PEM 公钥+签名）。

    产物写入 out_dir（默认配置文件所在目录）：
      zhixin-pri.pem / zhixin-pub.pem / mnemonic.txt
    ⚠ 助记词是私钥的唯一恢复途径，必须离线抄写保存；泄露即等于私钥泄露。
    """
    base = Path(out_dir) if out_dir else Path(cfg.get("_path", HERE / "zhixin.json")).parent
    base.mkdir(parents=True, exist_ok=True)
    pri_f, pub_f, mn_f = base / "zhixin-pri.pem", base / "zhixin-pub.pem", base / "mnemonic.txt"
    if (pri_f.exists() or pub_f.exists()) and not force:
        raise SystemExit(f"已存在密钥文件（{pri_f.name}/{pub_f.name}）。要覆盖请加 --force。")
    mn = _pick(local_service(cfg, "createMnemonic", {}), "mnemonic")
    kp = local_service(cfg, "deriveKeyPair", {"mnemonic": mn, "index": index})
    pri, pub = _pick(kp, "priKey"), _pick(kp, "pubKey")
    if not (pri and pub):
        raise SystemExit(f"本地服务未返回密钥对：{kp}")
    body = "".join(l for l in pub.splitlines() if "-----" not in l)
    addr = _pick(local_service(cfg, "pubKey2Address", {"pubKey": pub}), "address")
    pri_f.write_text(pri, encoding="utf-8")
    pub_f.write_text(pub, encoding="utf-8")
    mn_f.write_text(
        "# 至信链链账户助记词 —— 离线抄写保存，勿拍照上传、勿放云盘\n"
        "# 同一助记词 + 同一 index 可重新派生出同一对密钥；丢失则私钥不可恢复。\n"
        f"index={index}\n{mn}\n", encoding="utf-8")
    return {"ok": True, "out_dir": str(base), "index": index,
            "address": addr, "public_key_pem": pub.strip(),
            "public_key_body_len": len(body),
            "files": [str(pri_f), str(pub_f), str(mn_f)]}


def sign_challenge(cfg, text=None, path=None):
    """对待签名内容签名（控制台「新建链账户/申请凭证」用），返回 hex(DER 签名)。"""
    if path:
        raw = _clean_pem(Path(path).read_bytes())
    elif text is not None:
        raw = text
    else:
        raise SystemExit("需要 --challenge \"<待签名内容>\" 或 --challenge-file <文件>")
    # 控制台展示的待签内容可能带换行/首尾空格：原样签，但去掉末尾多余空行
    data = raw.rstrip("\r\n")
    sig = sm2_sign(cfg, _read_key(cfg, "pri"), data)
    ok = sm2_verify(cfg, _read_key(cfg, "pub"), data, sig)
    return {"ok": bool(ok), "signed_text": data, "signature": sig,
            "signature_len": len(sig), "self_verified": ok,
            "public_key_pem": _read_key(cfg, "pub").strip()}


def self_test(cfg=None):
    print("── 本地密码学自检（无凭据） ──")
    ok = True
    vectors = [(b"abc", "66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0"),
               (b"data", "9f81f163f99a5f57af861bdb036a1b9d9150293362b7ef5c0fd5c783dfbc44a8"),
               (b"", "1ab21d8355cfa17f8e61194831e81a8f22bec8c728fefb747ed035eb5082aa2b")]
    for msg, want in vectors:
        got = sm3_hex(msg)
        flag = "PASS" if got == want else "FAIL"
        ok &= got == want
        print(f"  SM3({msg!r:8}) {flag} {got[:16]}…")
    try:
        import gmssl.sm3 as g  # 仅用于交叉验证，缺失则跳过
        x = g.sm3_hash(list(b"data"))
        flag = "PASS" if x == sm3_hex(b"data") else "FAIL"
        ok &= flag == "PASS"
        print(f"  vs gmssl 库        {flag}")
    except ImportError:
        print("  vs gmssl 库        SKIP（未安装 gmssl，不影响使用）")

    if cfg is None:
        cfg = load_config(required=False) or {"local_service": DEFAULT_LOCAL_SERVICE}
    try:
        sd = local_api_sign(cfg, "testSecretId123", "testSecretKey456")
        local_sign = hmac_sm3(b"testSecretKey456",
                              f"testSecretId123&testSecretKey456&{sd['SignatureTime']}&{sd['Nonce']}".encode()).hex()
        flag = "PASS" if local_sign == sd["Signature"] else "FAIL"
        ok &= flag == "PASS"
        print(f"  HMAC-SM3 vs 官方服务 {flag}（服务={str(sd['Signature'])[:16]}… 本机={local_sign[:16]}…）")
        mnemonic = _pick(local_service(cfg, "createMnemonic", {}), "mnemonic")
        kp = local_service(cfg, "deriveKeyPair", {"mnemonic": mnemonic, "index": 0})
        pri, pub = _pick(kp, "priKey"), _pick(kp, "pubKey")
        msg = "WDY-self-test"
        sig = sm2_sign(cfg, pri, msg)
        v = sm2_verify(cfg, pub, msg, sig)
        flag = "PASS" if v else "FAIL"
        ok &= v
        print(f"  SM2 签/验（本地服务）{flag}")
        mine = service_sm3_hex(cfg, b"data")
        flag = "PASS" if mine == sm3_hex(b"data") else "FAIL"
        ok &= flag == "PASS"
        print(f"  SM3 vs 官方服务      {flag}")
    except RuntimeError as e:
        print(f"  官方本地服务自检     SKIP — {str(e).splitlines()[0]}")

    try:
        cfg2 = cfg if cfg.get("secretId") else load_config(required=False)
        if cfg2:
            r = _http(cfg2, "GET", "/api/v1/ev/query", params={"evId": "PROBE-NOT-EXIST"})
            print(f"  线上连通性探测       retCode={r.get('retCode')} retMsg={r.get('retMsg')}"
                  f"（140005=Secret-Id 不存在，说明请求格式已被服务端接受）")
    except Exception as e:
        print(f"  线上连通性探测       SKIP — {e}")

    print("── 自检结果：", "全部通过" if ok else "存在 FAIL，先修再上线", "──")
    return 0 if ok else 1


def main():
    ap = argparse.ArgumentParser(description="WDY → 至信链 存证锚定 transport")
    ap.add_argument("--hash", help="WDY 记录的 SHA-256（64 hex）")
    ap.add_argument("--record", help="WDY 记录号，如 WDY-2026-0003")
    ap.add_argument("--file", help="原件路径（上链 hash 用文件 SM3；会校验 sha256 是否相符）")
    ap.add_argument("--manifest", help="manifest.json 路径（--target manifest 或回写用）")
    ap.add_argument("--target", default="auto", choices=["auto", "file", "manifest", "sha256hex"])
    ap.add_argument("--note", default=None, help="写进 extendInfo 的备注")
    ap.add_argument("--key", default=None, help="Ed25519 私钥（回写重签用）")
    ap.add_argument("--key-id", default="2026-rot1")
    ap.add_argument("--out", default=None, help="回写输出的 manifest 路径（默认原地覆盖）")
    ap.add_argument("--config", default=None, help="指定 zhixin.json 路径（默认 tools/ 或当前目录）")
    ap.add_argument("--mode", default=None, choices=["live", "mock"], help="覆盖配置里的 mode")
    ap.add_argument("--dry-run", action="store_true", help="只打印将发出的请求，不调用")
    ap.add_argument("--no-wait", action="store_true", help="不等入链确认（默认会轮询 ev/query 直到上链）")
    ap.add_argument("--get-cert", action="store_true", dest="get_cert", help="成功后创建并拉取存证证书 certUrl")
    ap.add_argument("--query-ev", default=None, help="按 evId 查询存证结果")
    ap.add_argument("--query-hash", default=None, help="按 SM3 hash 查询")
    ap.add_argument("--cert", default=None, help="按 evId 生成/查询存证证书")
    ap.add_argument("--self-test", action="store_true", help="本地密码学 + 官方服务自检")
    ap.add_argument("--keygen", action="store_true", help="生成链账户 SM2 密钥对（写入配置文件同目录）")
    ap.add_argument("--key-index", type=int, default=0, help="派生用的 index（默认 0）")
    ap.add_argument("--force", action="store_true", help="--keygen 时覆盖已有密钥文件")
    ap.add_argument("--challenge", default=None, help="待签名内容（控制台新建链账户/申请凭证）")
    ap.add_argument("--challenge-file", default=None, help="待签名内容所在文件")
    a = ap.parse_args()

    if a.keygen or a.challenge or a.challenge_file:
        cfg = load_config(explicit=a.config, strict_creds=False)
        if a.mode:
            cfg["mode"] = a.mode
        if a.keygen:
            r = keygen(cfg, index=a.key_index, force=a.force)
            print(json.dumps(r, ensure_ascii=False, indent=2))
            print("\n把上面的 public_key_pem 整段粘贴到控制台「公钥」框；再用 --challenge 拿到「签名」。")
        if a.challenge or a.challenge_file:
            r = sign_challenge(cfg, text=a.challenge, path=a.challenge_file)
            print(json.dumps(r, ensure_ascii=False, indent=2))
            if not r["ok"]:
                sys.exit(1)
        sys.exit(0)

    if a.self_test:
        cfg = load_config(required=False, explicit=a.config, strict_creds=False)
        if cfg and a.mode:
            cfg["mode"] = a.mode
        sys.exit(self_test(cfg))

    cfg = load_config(explicit=a.config)
    if a.mode:
        cfg["mode"] = a.mode

    if a.query_ev or a.query_hash:
        r = api_ev_query(cfg, ev_id=a.query_ev, hash_hex=a.query_hash)
        print(json.dumps(r, ensure_ascii=False, indent=2))
        sys.exit(0 if r.get("retCode") == 0 else 1)
    if a.cert:
        create = api_ev_cert(cfg, a.cert, create=True)
        query = api_ev_cert(cfg, a.cert)
        print(json.dumps({"create": create, "query": query,
                          "cert_url": cert_page_url(a.cert)}, ensure_ascii=False, indent=2))
        sys.exit(0 if query.get("retCode") == 0 else 1)

    if not (a.hash and a.record):
        ap.error("需要 --hash 与 --record（或用 --self-test / --query-ev / --cert）")

    res = anchor(a.hash, a.record, file_path=a.file, manifest_path=a.manifest,
                 target=a.target, extra_note=a.note, cfg=cfg, dry_run=a.dry_run,
                 wait=not a.no_wait, get_cert=a.get_cert)
    print(json.dumps(res, ensure_ascii=False, indent=2))
    if not res.get("ok"):
        sys.exit(3 if res.get("pending") else 1)
    if a.manifest and a.key and not a.dry_run:
        out = merge_into_manifest(a.manifest, res, a.key, a.key_id, a.out)
        print(f"manifest 已回写并重签：{out}")
    elif a.manifest and not a.dry_run and not a.key:
        print("提示：给了 --manifest 但没给 --key，未回写。回写需 --key <ed25519私钥>。", file=sys.stderr)


if __name__ == "__main__":
    main()
