#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称：door_token_client.py
模块描述：门口屏模拟器 Token 获取客户端

真实 Android 门口屏设备（UbGview 应用）启动后的第一个动作是调用
HTTP 接口 POST /exapi/api-android/token/getTokenInfoByToken 获取 token。

本客户端负责：
- 构造请求头（X-SIGN 固定值 / X-TIMESTAMP 秒级时间戳 / X-RANDOM 16位随机）
- 构造请求体（appToken / regUdid）
- appToken、regUdid 唯一性分配（批量场景不重复）
- 调用接口、解析响应、保存 Authorization / token / conference / company

签名说明（重要）：
- 真实设备日志中的 X-SIGN 为 108 字符（AES-CBC 加密，秒级时间戳）
- 现有 http_client.py 的算法（毫秒时间戳）与日志不匹配，无法本地复现
- 本期按已确认方案：复用日志中已验证可通过的固定 X-SIGN 值

作者：czj
创建日期：2026-08-19
最后修改：2026-08-19
"""

import hashlib
import json
import logging
import random
import re
import string
import threading
import time
from datetime import datetime, timedelta
from typing import Dict, List, Optional

import requests
import urllib3

# 尝试导入 pycryptodome（动态签名 AES-CBC 加密用）
try:
    from Crypto.Cipher import AES
    from Crypto.Util.Padding import pad
    import base64 as b64
    HAS_CRYPTO = True
except ImportError:
    HAS_CRYPTO = False

logger = logging.getLogger(__name__)

# 关闭测试环境自签名证书告警
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

# ==================== 常量（从真实设备日志确认） ====================

# 接口调用路径（真实设备日志），如 https://192.168.5.48/exapi/...
TOKEN_API_PATH = "/exapi/api-android/token/getTokenInfoByToken"

# 无纸化会议设备 Token 接口路径（与门口屏差异：无 /exapi 前缀）
PAPERLESS_TOKEN_API_PATH = "/api/api-android/token/getTokenInfoByToken"

# 无纸化会议设备消息查询接口路径
PAPERLESS_MESSAGE_API_PATH = "/api/message/getMessageByAppToken"

# 消息列表接口路径（真实设备日志）：定时拉取会议消息
MESSAGE_LIST_API_PATH = "/exapi/message/getMsgPageList/encrypt"

# 今日会议人脸页面接口路径（真实设备日志）：定时拉取今日会议人脸数据
FACE_PAGE_API_PATH = "/exapi/manageUser/getTodayMeetingFacePage/encrypt"

# 全局配置接口路径（真实设备日志）：定时拉取系统全局配置
EX_GLOBAL_CONFIG_API_PATH = "/exapi/systemConfiguration/exGlobalConfig/encrypt"

# 固定 X-SIGN（真实设备日志样本，已实测可调用成功 HTTP 200）
# 真实设备日志：X-TIMESTAMP=1787131139（秒级）, X-RANDOM=13位
X_SIGN_FIXED = "mjDLL13nJnDM9sigjWxkLksYK4EpWOkaxv2QbIDhK6BCuqiN40J1D14arFdDYXXSG+Lv37btG/eKym7WoZ+5wZsKX7aDS3QPVIa05J2Al8g="

# 协议默认值：https（默认端口 443），可配置 http 走 80
DEFAULT_SCHEME = "https"

# 默认 appToken 前缀（AND = Android 门口屏；真实日志为 AND-34H-0101）
DEFAULT_APP_TOKEN_PREFIX = "AND"

# appToken 公司分片编号（默认 34H，来自日志 AND-34H-0101）
DEFAULT_APP_TOKEN_SEGMENT = "34H"


def normalize_host(host: str) -> str:
    """
    规范化主机地址，确保形如 host:port（默认 HTTPS 443）

    Args:
        host: 主机地址，如 "192.168.5.48" 或 "192.168.5.48:8080"
             或完整 URL "https://192.168.5.48/exapi/"

    Returns:
        str: 规范化后的主机地址（不含协议和路径）
    """
    host = (host or "").strip()
    if not host:
        raise ValueError("token_api_host 不能为空")

    # 1. 去掉协议前缀
    if "://" in host:
        host = host.split("://", 1)[1]

    # 2. 去掉路径（取第一个 / 之前）
    host = host.split("/", 1)[0]

    # 3. 已经是 host:port 形式则保持
    if re.match(r"^[\w.\-]+:\d+$", host):
        return host
    return host


def pick_default_host() -> str:
    """
    从 MQTT 环境配置中选中默认 token 接口主机

    优先级：
    1. EnvConfig.default_topic_params['token_api_host']
    2. EnvConfig.broker_host（MQTT broker 与 token 接口同机时）

    Returns:
        str: token 接口主机地址
    """
    from app.models.device_sim import EnvConfig
    from app.database import async_session_factory

    try:
        # 同步阻塞轮询所有环境配置，选择第一个配置了 token_api_host 的
        import asyncio

        loop = asyncio.new_event_loop()
        try:
            asyncio.set_event_loop(loop)
            result = loop.run_until_complete(_pick_default_host_async())
        finally:
            loop.close()
        return result
    except Exception as e:
        logger.warning(f"获取默认 token 接口主机失败，使用空值: {e}")
        return ""


async def _pick_default_host_async() -> str:
    """异步轮询环境配置获取默认 token 接口主机"""
    from app.models.device_sim import EnvConfig
    from app.database import async_session_maker

    async with async_session_maker() as db:
        from sqlalchemy import select

        result = await db.execute(select(EnvConfig))
        configs = list(result.scalars().all())
        if not configs:
            return ""
        # 1. 优先 default_topic_params.token_api_host
        for cfg in configs:
            params = cfg.default_topic_params or {}
            if params.get("token_api_host"):
                return str(params["token_api_host"]).strip()
        # 2. 回退 broker_host
        for cfg in configs:
            if cfg.broker_host:
                return cfg.broker_host.strip()
        return ""


def _normalize_segment(raw: str) -> str:
    """规范化公司分片编号：去除非字母数字，限长 4"""
    cleaned = re.sub(r"[^A-Za-z0-9]", "", str(raw or ""))
    return (cleaned or DEFAULT_APP_TOKEN_SEGMENT)[:4]


def _device_seed(device_id: str) -> int:
    """由 device_id 生成稳定的正整数种子（同设备多次启动结果一致）"""
    digest = hashlib.md5(str(device_id).encode("utf-8")).hexdigest()
    return int(digest[:8], 16)  # md5 前 8 hex → 32 位无符号整数


def generate_reg_udid(device_id: str) -> str:
    """
    由 device_id 派生唯一的 regUdid（确定性，同设备多次启动一致）

    格式：reg_{md5(device_id)前16位}
    regUdid 为 16 位十六进制，仅含小写字母和数字，避免注册字符集冲突。

    Args:
        device_id: 设备 ID

    Returns:
        str: 16 位 regUdid
    """
    digest = hashlib.md5(str(device_id).encode("utf-8")).hexdigest()[:16]
    return f"reg_{digest}"


def generate_paperless_reg_udid(device_id: str) -> str:
    """
    由 device_id 派生无纸化设备的 regUdid（确定性，同设备多次启动一致）

    与门口屏的区别：无纸化真实设备日志 regUdid 为**纯 16 位 hex，无 reg_ 前缀**
    （如日志中的 54035332b33e1e30），而门口屏为 reg_{hex} 格式。

    Args:
        device_id: 设备 ID

    Returns:
        str: 16 位纯 hex regUdid（无前缀）
    """
    return hashlib.md5(str(device_id).encode("utf-8")).hexdigest()[:16]


class DoorTokenClient:
    """
    门口屏模拟器 token 获取客户端

    负责 getTokenInfoByToken 接口的调用，支持：
    - 固定 X-SIGN 签名头
    - appToken / regUdid 唯一性分配
    - 超时重试、响应解析

    Attributes:
        host (str): token 接口主机（如 "192.168.5.48"）
        device_id (str): 设备 ID
        app_token (str): 分配的 appToken
        reg_udid (str): 分配的 regUdid
        timeout (float): 请求超时（秒）
        max_retries (int): 网络错误最大重试次数
        _session (requests.Session): 复用的会话
    """

    def __init__(
        self,
        host: str,
        device_id: str,
        app_token: Optional[str] = None,
        x_sign: Optional[str] = None,
        timeout: float = 5.0,
        max_retries: int = 3,
        scheme: str = DEFAULT_SCHEME,
        token_api_path: str = TOKEN_API_PATH,
        reg_udid_override: Optional[str] = None,
    ):
        """
        初始化客户端

        Args:
            host: token 接口主机（如 "192.168.5.48" 或 "192.168.5.48:8080"）
            device_id: 设备 ID
            app_token: 分配的 appToken；为 None 时自动分配
            x_sign: 固定 X-SIGN；为 None 时使用内置默认值
            timeout: 请求超时（秒）
            max_retries: 网络错误最大重试次数（默认 3）
            scheme: 协议（https 默认 / http）
            token_api_path: Token 接口 API 路径（门口屏用 TOKEN_API_PATH，无纸化用 PAPERLESS_TOKEN_API_PATH）
            reg_udid_override: 外部派生的 regUdid；为 None 时内部调用 generate_reg_udid()
        """
        if not host:
            raise ValueError("host 不能为空，需提供 token 接口主机地址")

        # 从原始输入自动识别协议（如 "https://192.168.5.48" 或 "192.168.5.48:8080"）
        raw_host = str(host).strip()
        raw_lower = raw_host.lower()
        if raw_lower.startswith("https://"):
            scheme = "https"
        elif raw_lower.startswith("http://"):
            scheme = "http"
        elif raw_lower.startswith("wss://"):
            scheme = "https"  # wss 视为带 TLS 的 https
        elif raw_lower.startswith("ws://"):
            scheme = "http"

        self.host = normalize_host(host)
        self.device_id = device_id
        self.timeout = timeout
        self.max_retries = max_retries
        self.scheme = (scheme or DEFAULT_SCHEME).lower().rstrip(":")
        if self.scheme not in ("http", "https"):
            raise ValueError(f"不支持的协议: {self.scheme}")

        # 唯一性分配
        self.app_token = app_token or tokenizer.allocate_app_token(device_id)
        # regUdid：外部传入优先（无纸化），否则内部派生（门口屏）
        if reg_udid_override is not None:
            self.reg_udid = reg_udid_override
        else:
            self.reg_udid = generate_reg_udid(device_id)

        # 日志中的固定 X-SIGN
        self.x_sign = x_sign or X_SIGN_FIXED

        # API 路径（门口屏/无纸化不同）
        self._token_api_path = token_api_path

        logger.info(
            f"TokenClient 初始化: device_id={device_id}, app_token={self.app_token}, "
            f"reg_udid={self.reg_udid}, token_api_path={self._token_api_path}"
        )

        self._session = self._create_session()

    @property
    def base_url(self) -> str:
        """接口基础 URL：{scheme}://{host}/"""
        return f"{self.scheme}://{self.host}"

    def _create_session(self) -> requests.Session:
        """创建带连接级重试的会话（网络错误可重试）"""
        from requests.adapters import HTTPAdapter
        from urllib3.util.retry import Retry

        session = requests.Session()
        retry = Retry(
            total=self.max_retries,
            backoff_factor=0.5,
            status_forcelist=[500, 502, 503, 504],
            allowed_methods=["GET", "POST"],
            raise_on_status=False,
        )
        adapter = HTTPAdapter(max_retries=retry)
        session.mount("http://", adapter)
        session.mount("https://", adapter)
        return session

    @staticmethod
    def _generate_random_string(min_len: int = 8, max_len: int = 16) -> str:
        """生成指定长度的随机字符串（字母+数字）"""
        length = random.randint(min_len, max_len)
        chars = string.ascii_letters + string.digits
        return "".join(random.choice(chars) for _ in range(length))

    @staticmethod
    def _sha256(text: str) -> str:
        """计算 SHA256 哈希值"""
        return hashlib.sha256(text.encode("utf-8")).hexdigest()

    @staticmethod
    def _aes_encrypt(plaintext: str, key_str: str, iv_str: str) -> str:
        """
        AES CBC 模式加密（与前端 CryptoJS / http_client.py 行为一致）
        CryptoJS 传入字符串时按 UTF-8 编码为密钥/IV，不是 hex 解析。

        Args:
            plaintext: 明文字符串（SHA256 后的 hex 字符串）
            key_str: 16 字符 AES 密钥字符串
            iv_str: 16 字符 AES IV 字符串

        Returns:
            str: Base64 编码的密文
        """
        if not HAS_CRYPTO:
            logger.warning("pycryptodome 未安装，X-SIGN 将降级为明文（可能导致服务端校验失败）")
            return plaintext

        key_bytes = key_str.encode("utf-8")   # 16 bytes (AES-128)
        iv_bytes = iv_str.encode("utf-8")     # 16 bytes
        cipher = AES.new(key_bytes, AES.MODE_CBC, iv_bytes)
        padded = pad(plaintext.encode("utf-8"), AES.block_size)
        encrypted = cipher.encrypt(padded)
        return b64.b64encode(encrypted).decode("utf-8")

    def _generate_dynamic_sign(self, params: Optional[dict], bearer_token: str) -> Dict[str, str]:
        """
        根据前端 JS 逆向的签名算法生成动态请求签名（用于消息列表接口）

        与 http_client.py 中已验证的算法一致：
        1. x_random = 随机字符串(8-16 位字母数字)
        2. x_timestamp = 当前秒级时间戳（与真实设备日志一致）
        3. sign_str = timestamp + JSON.stringify(params) + random（有参数时）
           sign_str = timestamp + random（无参数时）
        4. sign_hash = SHA256(sign_str)
        5. aes_key_source = SHA256(bearer_token)   → 64 字符 hex
        6. aes_key = aes_key_source[16:32]          → 16 字符
        7. aes_iv  = aes_key_source[0:8] + 末尾 8 字符 → 16 字符
        8. x_sign = AES.encrypt(sign_hash, aes_key, aes_iv) → Base64

        Args:
            params: 查询参数字典（GET 请求）
            bearer_token: Bearer Token 字符串（含 "Bearer " 前缀）

        Returns:
            dict: {X-RANDOM, X-TIMESTAMP, X-SIGN}
        """
        x_random = self._generate_random_string(8, 16)
        # 真实设备日志 X-TIMESTAMP=1787135913 为秒级，这里用秒级
        x_timestamp = str(int(time.time()))

        if params is not None:
            params_str = json.dumps(params, separators=(",", ":"), ensure_ascii=False)
            sign_str = x_timestamp + params_str + x_random
        else:
            sign_str = x_timestamp + x_random

        sign_hash = self._sha256(sign_str)

        # 用 Bearer token 做 AES 密钥源
        aes_key_source = self._sha256(bearer_token)
        aes_key = aes_key_source[16:32]
        aes_iv = aes_key_source[0:8] + aes_key_source[-8:]

        x_sign = self._aes_encrypt(sign_hash, aes_key, aes_iv)

        return {
            "X-RANDOM": x_random,
            "X-TIMESTAMP": x_timestamp,
            "X-SIGN": x_sign,
        }

    def get_message_list(self, authorization: str, conference_id: str,
                         timeout: Optional[float] = None) -> bool:
        """
        调用消息列表接口拉取会议消息（定时调用，仅保证调用成功）

        真实设备日志参考：
        GET /exapi/message/getMsgPageList/encrypt
            ?queryType=2&pageNum=1&conferenceId=50&pageSize=100
            &maxTime=2026-08-19 23:59:59&minTime=2026-08-18 18:38:33
        Authorization: Bearer {token}
        X-SIGN / X-TIMESTAMP / X-RANDOM（动态签名）

        本方法只校验 HTTP 200 + success=true，**不解析/不解密 result 加密内容**。

        Args:
            authorization: token 接口返回的 Authorization（不含 Bearer 前缀，
                           本方法会自动补 "Bearer " 前缀）
            conference_id: 会议 ID（来自 token 响应，已回填 topic_params.conference_id）
            timeout: 请求超时（秒），默认用 self.timeout

        Returns:
            bool: 是否调用成功（HTTP 200 + 业务成功）
        """
        if not authorization:
            logger.warning(f"[MsgList] 缺少 authorization，跳过消息列表调用: {self.device_id}")
            return False

        url = f"{self.base_url}{MESSAGE_LIST_API_PATH}"

        # 注意：真实设备日志中 Authorization 格式为 "BearereyJ..."（无空格）
        # 服务端仅接受此格式。token 接口返回的 authorization 已符合此格式。
        # 签名密钥源和 Authorization 请求头必须使用同一字符串
        bearer_str = authorization
        auth_header = authorization

        # 构造查询参数（minTime = 当前时间 - 24 小时，maxTime = 当前时间）
        now = datetime.now()
        min_time = now - timedelta(hours=24)
        time_fmt = "%Y-%m-%d %H:%M:%S"

        # 服务端真实日志：URL 编码后的空格为 %20，这里交给 requests 自动编码
        params = {
            "queryType": "2",
            "pageNum": "1",
            "conferenceId": str(conference_id) if conference_id else "0",
            "pageSize": "100",
            "maxTime": now.strftime(time_fmt),
            "minTime": min_time.strftime(time_fmt),
        }

        # 动态签名（基于 Bearer token + 查询参数）
        sign_headers = self._generate_dynamic_sign(params, bearer_str)

        headers = {
            "Authorization": auth_header,
            "Accept": "application/json, text/plain, */*",
        }
        headers.update(sign_headers)

        logger.info(
            f"[MsgList] 调用接口: GET {url}, device_id={self.device_id}, "
            f"conferenceId={params['conferenceId']}, "
            f"X-TIMESTAMP={headers['X-TIMESTAMP']}, X-RANDOM={headers['X-RANDOM']}"
        )

        try:
            resp = self._session.get(
                url,
                params=params,
                headers=headers,
                verify=False,
                timeout=timeout or self.timeout,
            )
        except (ConnectionError, requests.ConnectionError, requests.Timeout) as e:
            logger.warning(f"[MsgList] 网络错误: device_id={self.device_id}, error={e}")
            return False

        if resp.status_code != 200:
            logger.warning(
                f"[MsgList] HTTP 非 200: device_id={self.device_id}, "
                f"status={resp.status_code}, body={resp.text[:300]}"
            )
            return False

        # 仅校验业务成功，不解析加密 result
        try:
            data = resp.json()
        except Exception as e:
            logger.warning(f"[MsgList] 响应 JSON 解析失败: device_id={self.device_id}, error={e}")
            return False

        code = str(data.get("code", ""))
        success = data.get("success", False)
        if code not in ("200", 200) or not success:
            logger.warning(
                f"[MsgList] 业务失败: device_id={self.device_id}, "
                f"code={code}, message={data.get('message', '')}"
            )
            return False

        # 仅记录 result 长度，不解密
        result = data.get("result", "")
        result_len = len(str(result)) if result else 0
        logger.info(
            f"[MsgList] 调用成功: device_id={self.device_id}, "
            f"message={data.get('message', '')}, result_len={result_len}"
        )
        return True

    def get_today_meeting_face_page(self, authorization: str, conference_id: str,
                                    company_number: str = "",
                                    timeout: Optional[float] = None) -> bool:
        """
        调用今日会议人脸页面接口拉取人脸数据（定时调用，仅保证调用成功）

        真实设备日志参考：
        POST https://192.168.5.48/exapi/manageUser/getTodayMeetingFacePage/encrypt
        Authorization: BearereyJ...
        X-SIGN / X-TIMESTAMP / X-RANDOM（动态签名）
        Body: conferenceId={conference_id}（form-urlencoded）

        【实测参数发现】服务端要求 body 必须包含 companyNumber：
        - 不传 companyNumber → A0002 缺少参数
        - 传 companyNumber → 200 OPERATION_SUCCESSFUL
        （conferenceId 可选，同时传也不影响成功）

        本方法只校验 HTTP 200 + success=true，**不解析/不解密 result 加密内容**。

        Args:
            authorization: token 接口返回的 Authorization（无空格格式 BearereyJ...）
            conference_id: 会议 ID（来自 token 响应，已回填 topic_params.conference_id）
            company_number: 公司编号（来自 token 响应 companyNumber，必须提供）
            timeout: 请求超时（秒），默认用 self.timeout

        Returns:
            bool: 是否调用成功（HTTP 200 + 业务成功）
        """
        if not authorization:
            logger.warning(f"[FacePage] 缺少 authorization，跳过人脸页面调用: {self.device_id}")
            return False

        url = f"{self.base_url}{FACE_PAGE_API_PATH}"

        # Authorization 格式必须为无空格 BearereyJ...（与真实设备日志一致）
        auth_header = authorization
        bearer_str = authorization

        # 构造请求体（form-urlencoded）
        # 实测必须含 companyNumber，否则返回 A0002 缺少参数
        body_data = {
            "conferenceId": str(conference_id) if conference_id else "0",
            "companyNumber": company_number,
        }

        # 动态签名（基于 Bearer token + body 参数）
        sign_headers = self._generate_dynamic_sign(body_data, bearer_str)

        headers = {
            "Authorization": auth_header,
            "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
            "Accept": "application/json, text/plain, */*",
        }
        headers.update(sign_headers)

        logger.info(
            f"[FacePage] 调用接口: POST {url}, device_id={self.device_id}, "
            f"conferenceId={body_data['conferenceId']}, "
            f"X-TIMESTAMP={headers['X-TIMESTAMP']}, X-RANDOM={headers['X-RANDOM']}"
        )

        try:
            resp = self._session.post(
                url,
                data=body_data,
                headers=headers,
                verify=False,
                timeout=timeout or self.timeout,
            )
        except (ConnectionError, requests.ConnectionError, requests.Timeout) as e:
            logger.warning(f"[FacePage] 网络错误: device_id={self.device_id}, error={e}")
            return False

        if resp.status_code != 200:
            logger.warning(
                f"[FacePage] HTTP 非 200: device_id={self.device_id}, "
                f"status={resp.status_code}, body={resp.text[:300]}"
            )
            return False

        # 仅校验业务成功，不解析加密 result
        try:
            data = resp.json()
        except Exception as e:
            logger.warning(f"[FacePage] 响应 JSON 解析失败: device_id={self.device_id}, error={e}")
            return False

        code = str(data.get("code", ""))
        success = data.get("success", False)
        if code not in ("200", 200) or not success:
            logger.warning(
                f"[FacePage] 业务失败: device_id={self.device_id}, "
                f"code={code}, message={data.get('message', '')}"
            )
            return False

        # 仅记录 result 长度，不解密
        result = data.get("result", "")
        result_len = len(str(result)) if result else 0
        logger.info(
            f"[FacePage] 调用成功: device_id={self.device_id}, "
            f"message={data.get('message', '')}, result_len={result_len}"
        )
        return True

    def get_ex_global_config(self, authorization: str, company_number: str,
                             timeout: Optional[float] = None) -> bool:
        """
        调用全局配置接口拉取系统全局配置（定时调用，仅保证调用成功）

        真实设备日志参考：
        GET https://192.168.5.48/exapi/systemConfiguration/exGlobalConfig/encrypt?companyNumber=CN-34H-UBAINS
        Content-Type: application/json
        Authorization: BearereyJ...
        X-SIGN / X-TIMESTAMP / X-RANDOM（动态签名）

        本方法只校验 HTTP 200 + success=true，**不解析/不解密 result 加密内容**。

        Args:
            authorization: token 接口返回的 Authorization（无空格格式 BearereyJ...）
            company_number: 公司编号（来自 token 响应 companyNumber，必须提供）
            timeout: 请求超时（秒），默认用 self.timeout

        Returns:
            bool: 是否调用成功（HTTP 200 + 业务成功）
        """
        if not authorization:
            logger.warning(f"[GlobalConfig] 缺少 authorization，跳过全局配置调用: {self.device_id}")
            return False

        url = f"{self.base_url}{EX_GLOBAL_CONFIG_API_PATH}"

        # Authorization 格式必须为无空格 BearereyJ...（与真实设备日志一致）
        auth_header = authorization
        bearer_str = authorization

        # 构造查询参数
        params = {"companyNumber": company_number}

        # 动态签名（基于 Bearer token + 查询参数）
        sign_headers = self._generate_dynamic_sign(params, bearer_str)

        headers = {
            "Authorization": auth_header,
            "Content-Type": "application/json",
            "Accept": "application/json, text/plain, */*",
        }
        headers.update(sign_headers)

        logger.info(
            f"[GlobalConfig] 调用接口: GET {url}, device_id={self.device_id}, "
            f"companyNumber={company_number}, "
            f"X-TIMESTAMP={headers['X-TIMESTAMP']}, X-RANDOM={headers['X-RANDOM']}"
        )

        try:
            resp = self._session.get(
                url,
                params=params,
                headers=headers,
                verify=False,
                timeout=timeout or self.timeout,
            )
        except (ConnectionError, requests.ConnectionError, requests.Timeout) as e:
            logger.warning(f"[GlobalConfig] 网络错误: device_id={self.device_id}, error={e}")
            return False

        if resp.status_code != 200:
            logger.warning(
                f"[GlobalConfig] HTTP 非 200: device_id={self.device_id}, "
                f"status={resp.status_code}, body={resp.text[:300]}"
            )
            return False

        # 仅校验业务成功，不解析加密 result
        try:
            data = resp.json()
        except Exception as e:
            logger.warning(f"[GlobalConfig] 响应 JSON 解析失败: device_id={self.device_id}, error={e}")
            return False

        code = str(data.get("code", ""))
        success = data.get("success", False)
        if code not in ("200", 200) or not success:
            logger.warning(
                f"[GlobalConfig] 业务失败: device_id={self.device_id}, "
                f"code={code}, message={data.get('message', '')}"
            )
            return False

        # 仅记录 result 长度，不解密
        result = data.get("result", "")
        result_len = len(str(result)) if result else 0
        logger.info(
            f"[GlobalConfig] 调用成功: device_id={self.device_id}, "
            f"message={data.get('message', '')}, result_len={result_len}"
        )
        return True

    def get_message_by_app_token(self, authorization: str, app_token: str,
                                  timeout: Optional[float] = None) -> bool:
        """
        调用无纸化消息查询接口定期拉取会议消息（定时调用，仅保证调用可达）

        真实设备日志参考：
        POST https://192.168.5.240/api/message/getMessageByAppToken
        Authorization: BearereyJ...
        X-SIGN / X-TIMESTAMP / X-RANDOM（动态签名）
        Content-Type: application/x-www-form-urlencoded
        Body: appToken={app_token}

        本方法只校验 HTTP 200，**业务码非 200 不视为失败**：
        - A0034 "会议不存在"是正常业务场景（无进行中的会议），真实设备同样收到此响应
        - HTTP 200 即算调用成功

        Args:
            authorization: token 接口返回的 Authorization（无空格格式 BearereyJ...）
            app_token: 授权码（来自导入时提供的 app_token）
            timeout: 请求超时（秒），默认用 self.timeout

        Returns:
            bool: 是否调用成功（HTTP 200 即为成功，无论业务码）
        """
        if not authorization:
            logger.warning(f"[PaperlessMsg] 缺少 authorization，跳过消息查询: {self.device_id}")
            return False

        if not app_token:
            logger.warning(f"[PaperlessMsg] 缺少 app_token，跳过消息查询: {self.device_id}")
            return False

        url = f"{self.base_url}{PAPERLESS_MESSAGE_API_PATH}"

        # Authorization 格式必须为无空格 BearereyJ...（与真实设备日志一致）
        auth_header = authorization
        bearer_str = authorization

        # 构造请求体（form-urlencoded）
        body_data = {"appToken": app_token}

        # 动态签名（基于 Bearer token + body 参数）
        sign_headers = self._generate_dynamic_sign(body_data, bearer_str)

        headers = {
            "Authorization": auth_header,
            "Content-Type": "application/x-www-form-urlencoded",
            "Accept": "application/json, text/plain, */*",
        }
        headers.update(sign_headers)

        logger.info(
            f"[PaperlessMsg] 调用接口: POST {url}, device_id={self.device_id}, "
            f"appToken={app_token}, "
            f"X-TIMESTAMP={headers['X-TIMESTAMP']}, X-RANDOM={headers['X-RANDOM']}"
        )

        try:
            resp = self._session.post(
                url,
                data=body_data,
                headers=headers,
                verify=False,
                timeout=timeout or self.timeout,
            )
        except (ConnectionError, requests.ConnectionError, requests.Timeout) as e:
            logger.warning(f"[PaperlessMsg] 网络错误: device_id={self.device_id}, error={e}")
            return False

        if resp.status_code != 200:
            logger.warning(
                f"[PaperlessMsg] HTTP 非 200: device_id={self.device_id}, "
                f"status={resp.status_code}, body={resp.text[:300]}"
            )
            return False

        # HTTP 200 即视为成功（不校验业务码，A0034 会议不存在是正常业务场景）
        try:
            data = resp.json()
        except Exception as e:
            logger.warning(f"[PaperlessMsg] 响应 JSON 解析失败: device_id={self.device_id}, error={e}")
            # HTTP 200 + 无法解析 JSON 仍算调用可达
            return True

        code = str(data.get("code", ""))
        message = data.get("message", "")
        logger.info(
            f"[PaperlessMsg] 调用成功: device_id={self.device_id}, "
            f"code={code}, message={message}"
        )
        return True

    def _build_headers(self, x_timestamp: Optional[str] = None, x_random: Optional[str] = None) -> Dict[str, str]:
        """
        构造请求头

        Args:
            x_timestamp: 秒级时间戳；为 None 自动生成
            x_random: 随机串；为 None 自动生成 16 位

        Returns:
            dict: 请求头
        """
        headers = {
            "Content-Type": "application/json; charset=utf-8",
            "Accept": "application/json, text/plain, */*",
            "X-SIGN": self.x_sign,
            "X-TIMESTAMP": x_timestamp or str(int(time.time())),
            "X-RANDOM": x_random or "".join(
                random.choices(string.ascii_letters + string.digits, k=16)
            ),
        }
        return headers

    def _build_body(self) -> Dict[str, str]:
        """构造请求体"""
        return {
            "appToken": self.app_token,
            "regUdid": self.reg_udid,
        }

    @staticmethod
    def _parse_response(resp: requests.Response) -> dict:
        """
        解析接口响应

        注意：实际响应中 code 为字符串 "200"，
        conference 和 company 嵌套在 token 对象内部。

        Args:
            resp: HTTP 响应对象

        Returns:
            dict: {authorization, token, conference, company}

        Raises:
            ValueError: 响应解析失败或业务失败
        """
        try:
            data = resp.json()
        except Exception as e:
            raise ValueError(f"响应 JSON 解析失败: {e}, body={resp.text[:500]}")

        if not isinstance(data, dict):
            raise ValueError(f"响应格式异常: {str(data)[:200]}")

        code = str(data.get("code", ""))
        success = data.get("success", False)
        if code not in ("200", 200) or not success:
            message = data.get("message", "")
            raise ValueError(f"接口业务失败: code={code}, message={message}")

        result = data.get("result") or {}
        authorization = result.get("Authorization") or ""
        if not authorization:
            raise ValueError("响应缺少 Authorization 字段")

        # 注意：实际接口响应中 conference 和 company 嵌套在 token 内部
        token_info = result.get("token") or {}
        conference_info = token_info.get("conference") or {}
        company_info = token_info.get("company") or {}

        # 去掉 token 中嵌套的 conference/company，保持顶层干净
        token_clean = dict(token_info)
        token_clean.pop("conference", None)
        token_clean.pop("company", None)

        return {
            "authorization": authorization,
            "token": token_clean,
            "conference": conference_info,
            "company": company_info,
            "code": code,
            "message": data.get("message", ""),
        }

    def get_token(self) -> dict:
        """
        调用 getTokenInfoByToken 接口获取 token

        Returns:
            dict: {authorization, token, conference, company, ...}

        Raises:
            ConnectionError: 网络可达但业务失败（重试）后仍失败
            ValueError: 参数/解析错误
        """
        url = f"{self.base_url}{self._token_api_path}"
        headers = self._build_headers()
        body = self._build_body()

        logger.info(f"[Token] 调用接口: POST {url}")
        logger.info(f"[Token] 请求头: X-TIMESTAMP={headers['X-TIMESTAMP']}, X-RANDOM={headers['X-RANDOM']}")
        logger.info(f"[Token] 请求体: {body}")

        last_error: Optional[Exception] = None
        for attempt in range(1, self.max_retries + 1):
            try:
                resp = self._session.post(
                    url,
                    json=body,
                    headers=headers,
                    verify=False,
                    timeout=self.timeout,
                )
                logger.info(f"[Token] 响应: HTTP {resp.status_code}, body={resp.text[:500]}")
                if resp.status_code == 200:
                    parsed = self._parse_response(resp)
                    logger.info(f"[Token] 获取成功: device_id={self.device_id}, appToken={self.app_token}")
                    return parsed
                # 非 200 状态码：业务错误，不重试
                raise ValueError(f"HTTP {resp.status_code}: {resp.text[:300]}")
            except ValueError as e:
                # 业务错误：不重试
                raise e
            except (ConnectionError, requests.ConnectionError, requests.Timeout) as e:
                last_error = e
                logger.warning(f"[Token] 网络错误（第 {attempt}/{self.max_retries} 次）: {e}")
                if attempt < self.max_retries:
                    import time as _time
                    _time.sleep(2 ** (attempt - 1))  # 指数退避 1s, 2s, 4s

        raise ConnectionError(
            f"token 接口调用失败（重试 {self.max_retries} 次）: {last_error}"
        )


class TokenPool:
    """
    进程级 appToken 登记池（保证批量场景不重复）

    注意：appToken 不是自动生成的，而是导入设备时由用户提供的
    服务端已注册授权码（如 AND-34H-0101）。本池只做登记与去重：
    - 同一 appToken 在同一进程内只能被一台设备使用
    - 重复使用抛 ValueError，避免批量导入/启动时静默冲突
    """

    def __init__(
        self,
        prefix: str = DEFAULT_APP_TOKEN_PREFIX,
        segment: str = DEFAULT_APP_TOKEN_SEGMENT,
    ):
        """
        初始化登记池

        Args:
            prefix: appToken 固定前缀（AND，仅供参考不做生成）
            segment: 公司分片编号（仅供参考不做生成）
        """
        self._prefix = prefix.upper()
        self._segment = _normalize_segment(segment)
        self._used: set = set()
        self._lock = threading.Lock()

    def allocate(self, device_id: str, app_token: Optional[str] = None) -> str:
        """
        登记设备使用的 appToken（必须由导入提供，不自动生成）

        Args:
            device_id: 设备 ID
            app_token: 用户提供的 appToken（Excel 导入/手动创建）

        Returns:
            str: 登记后的 appToken

        Raises:
            ValueError: appToken 为空或已被其他设备使用
        """
        with self._lock:
            if not app_token:
                raise ValueError(
                    f"设备 {device_id} 未配置 appToken：门口屏 token 获取必须使用"
                    f"导入设备时提供的服务端授权码，不可自动生成"
                )
            if app_token in self._used:
                raise ValueError(f"appToken 已被其他设备使用，批量场景授权码不能重复: {app_token}")
            self._used.add(app_token)
            return app_token


# 全局单例（进程内共享，保证跨设备分配唯一）
tokenizer = TokenPool()


def allocate_app_token(device_id: str, app_token: Optional[str] = None) -> str:
    """
    登记设备使用的 appToken（由导入提供，不自动生成）

    Args:
        device_id: 设备 ID
        app_token: 用户提供的 appToken（Excel 导入/手动创建）

    Returns:
        str: 登记后的 appToken

    Raises:
        ValueError: 未提供 appToken 或重复使用
    """
    return tokenizer.allocate(device_id, app_token)


__all__ = [
    "DoorTokenClient",
    "TokenPool",
    "tokenizer",
    "allocate_app_token",
    "generate_reg_udid",
    "generate_paperless_reg_udid",
    "normalize_host",
    "pick_default_host",
    "TOKEN_API_PATH",
    "PAPERLESS_TOKEN_API_PATH",
    "PAPERLESS_MESSAGE_API_PATH",
    "MESSAGE_LIST_API_PATH",
    "FACE_PAGE_API_PATH",
    "EX_GLOBAL_CONFIG_API_PATH",
    "X_SIGN_FIXED",
    "DEFAULT_APP_TOKEN_PREFIX",
    "DEFAULT_APP_TOKEN_SEGMENT",
]