#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
开发者 Webhook 测试接收器

监听 8080 端口，接收平台推送的开发者事件，实现：
  1. 验签（HMAC-SHA256 + 常量时间比较）
  2. 防重放（时间戳窗口 300 秒）
  3. 打印完整事件内容（headers + 原始 body + 解析后的 JSON）
  4. 按规范返回 2xx 以重置失败计数

用法：
  python3 webhook_receiver.py [secret] [port]

  - secret: 开发者中心生成的签名密钥（可选）
            优先级：命令行参数 > 环境变量/本地 .env 文件中的 WEBHOOK_SECRET
            若都未配置则跳过验签，仅打印，便于联调
  - port  : 监听端口，默认 8080

依赖：
  pip install flask flask-cors
"""

import hmac
import hashlib
import time
import sys
import os
import json
from datetime import datetime, timezone

try:
    from flask import Flask, request, jsonify
    from flask_cors import CORS
except ImportError:
    print("缺少依赖，请先执行: pip install flask flask-cors", file=sys.stderr)
    sys.exit(1)

# 时间戳校验窗口（秒），与平台约定保持一致
TIMESTAMP_WINDOW = 300

# ==================== 人性化解释（参考 PHP: IotHostOperType / IotHostRecord::operatorDisplay） ====================

# 操作者类型 -> 中文名（对应 IotHostOperType）
OPER_TYPE_NAMES = {
    0: "无效",
    1: "面板按键",
    2: "遥控器",
    3: "探测器",
    4: "主机自身",
    5: "定时器",
    6: "APP",
    7: "管理员",
    8: "Web",
    9: "接警中心",
    10: "第三方",
    11: "中继报警",
}

# 事件码 -> 中文名（对应 AdemcoEvent::namesZh）
EVENT_NAMES = {
    3400: "离家布防", 1400: "撤防", 3456: "留守布防", 1456: "留守布防",
    1120: "紧急报警", 1130: "盗警", 1134: "门铃", 1110: "火警", 1121: "胁迫",
    1151: "燃气泄漏", 1113: "水泄漏", 1137: "主机防拆", 1383: "防区防拆",
    1570: "防区旁路", 1574: "系统旁路",
    3120: "紧急恢复", 3130: "盗警恢复", 3134: "门铃恢复", 3110: "火警恢复",
    3121: "胁迫恢复", 3151: "燃气恢复", 3113: "水泄漏恢复", 3137: "主机防拆恢复",
    3383: "防区防拆恢复", 3570: "防区旁路解除", 3574: "系统旁路解除",
    1301: "主机AC掉电", 1302: "低电", 1311: "坏电", 1387: "光扰", 1381: "失效",
    1393: "失联", 1384: "电源故障", 1380: "其他故障",
    3301: "主机AC复电", 3302: "低电恢复", 3311: "坏电恢复", 3387: "光扰恢复",
    3381: "失效恢复", 3393: "失联恢复", 3384: "电源故障恢复", 3380: "其他故障恢复",
    3100: "清除异常指示",
    1485: "485断开", 3485: "485连接", 1700: "链路挂起", 3700: "链路恢复",
    1701: "撤防密码错误", 1702: "分机探头异常", 3702: "分机探头恢复",
    1703: "分机电源异常", 3703: "分机电源恢复", 1704: "串口透传",
    2704: "进入设置状态", 3704: "退出设置状态", 1705: "查询分机信息",
    1706: "写入主机信息", 1707: "主机类型--网络模块", 1709: "手机用户SOS",
    1711: "手机用户消警", 1712: "主机进入设置状态", 3712: "主机退出设置状态",
    1710: "主机恢复出厂设置", 1713: "主机恢复出厂设置", 1944: "主机断线",
    1946: "主机上线",
}


def operator_display(oper_type, oper_group_id, oper_id):
    """参考 IotHostRecord::operatorDisplay() 的操作者描述"""
    type_name = OPER_TYPE_NAMES.get(oper_type, "未知")

    if oper_type in (1, 4):  # KEYPAD / HOST
        return type_name

    if oper_type in (2, 3, 5):  # REMOTE / SENSOR / TIMER
        display_id = "#%d-#%d" % (oper_group_id + 1, oper_id + 1)
        return "%s %s" % (type_name, display_id)

    if oper_type == 8:  # WEB
        if oper_id == 200:
            return "%s 主人" % type_name
        if 201 <= oper_id <= 255:
            return "%s 分享者 #%d" % (type_name, oper_id - 200)
        return "%s #%d" % (type_name, oper_id)

    if oper_type == 7:  # ADMIN
        if oper_id == 0:
            return "%s 串口软件" % type_name
        return "%s #%d" % (type_name, oper_id)

    if oper_type == 11:  # RELAY
        return "%s (来源:#%d, 原始事件:%d)" % (type_name, oper_group_id, oper_id)

    return "%s #%d" % (type_name, oper_id)


def event_name(event):
    """参考 AdemcoEvent::getName() 的事件中文名"""
    return EVENT_NAMES.get(event, "未知事件 %d" % event)


def human_description(rec):
    """生成记录的人性化解释（测试用，仅中文）"""
    operator = operator_display(
        rec.get("oper_type"), rec.get("oper_group_id"), rec.get("oper_id")
    )
    event = event_name(rec.get("event"))
    return "「%s」触发了「%s」" % (operator, event)

SECRET = None  # 默认不验签，仅打印；配置了密钥才开启验签


def _load_env(path=".env"):
    """极简 .env 加载：逐行解析 KEY=VALUE，支持 # 注释与引号包裹，已存在的环境变量不覆盖。"""
    if not os.path.isfile(path):
        return
    with open(path, "r", encoding="utf-8") as fh:
        for line in fh:
            line = line.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            key, _, value = line.partition("=")
            key = key.strip()
            value = value.strip()
            if not key:
                continue
            if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
                value = value[1:-1]
            os.environ.setdefault(key, value)


# 密钥优先级：命令行参数 > 环境变量 WEBHOOK_SECRET（含 .env）> 内置默认值（None，跳过验签）
if len(sys.argv) >= 2:
    SECRET = sys.argv[1].encode("utf-8")
else:
    _load_env(".env")
    if os.environ.get("WEBHOOK_SECRET"):
        SECRET = os.environ["WEBHOOK_SECRET"].encode("utf-8")

PORT = int(sys.argv[2]) if len(sys.argv) >= 3 else 8080

app = Flask(__name__)
CORS(app)  # 允许所有源跨域


def _now_local() -> str:
    return datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %z")


def verify_signature(raw_body: bytes) -> bool:
    """验签 + 防重放。SECRET 未配置时跳过验签，仅打印提示。"""
    received = request.headers.get("X-Server-Signature", "").replace("sha256=", "")
    timestamp_raw = request.headers.get("X-Server-Timestamp", "0")

    if SECRET is None:
        print("[*] 未配置 SECRET，跳过验签（仅打印）")
        return True

    try:
        timestamp = int(timestamp_raw)
    except ValueError:
        print("[!] X-Server-Timestamp 非法: %r" % timestamp_raw)
        return False

    # 1. 防重放
    if abs(time.time() - timestamp) > TIMESTAMP_WINDOW:
        print("[!] 时间戳超出窗口: %s (本地 %d)" % (timestamp_raw, int(time.time())))
        return False

    # 2. 计算本地签名
    local = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()

    # 3. 常量时间比较
    if not hmac.compare_digest(local, received):
        print("[!] 签名不匹配")
        print("    received = %s" % received)
        print("    local    = %s" % local)
        return False

    print("[+] 验签通过")
    return True


@app.route("/", methods=["POST"])
@app.route("/webhook", methods=["POST"])
def webhook():
    print("\n" + "=" * 70)
    print("[*] 收到推送 @ %s" % _now_local())
    print("=" * 70)

    raw_body = request.get_data()  # 原始 body 字节串，勿用 json 解析后再编码

    # 打印请求头
    print("[Headers]")
    for key, value in request.headers.items():
        print("  %s: %s" % (key, value))

    # 打印原始 body
    print("\n[Raw Body]")
    print(raw_body.decode("utf-8", errors="replace"))

    # 验签
    print("\n[Verification]")
    if not verify_signature(raw_body):
        return jsonify({"status": "error", "message": "invalid signature or timestamp"}), 401

    # 解析并打印结构化事件内容
    print("\n[Parsed Event]")
    try:
        payload = json.loads(raw_body)
        print(json.dumps(payload, ensure_ascii=False, indent=2))

        event_type = payload.get("event")
        device_id = payload.get("device_id")
        sent_at = payload.get("sent_at")
        data = payload.get("data")

        print("\n[Summary]")
        print("  event     = %s" % event_type)
        print("  device_id = %s" % device_id)
        if sent_at:
            ts = datetime.fromtimestamp(sent_at, tz=timezone.utc)
            print("  sent_at   = %s (%s)" % (sent_at, ts.isoformat()))

        # 针对两类事件，做点友好解析
        if event_type == "App\\Events\\EventIotNewRecord":
            records = (data or {}).get("records", [])
            print("  records   = %d 条" % len(records))
            for i, rec in enumerate(records):
                print("    [%d] host_device_id=%s oper_type=%s oper_id=%s event=%s ts=%s"
                      % (i, rec.get("host_device_id"), rec.get("oper_type"),
                         rec.get("oper_id"), rec.get("event"), rec.get("timestamp")))
                print("         解释: %s" % human_description(rec))
        elif event_type == "App\\Events\\EventIotRamSync":
            print("  type      = %s" % (data or {}).get("type"))
            print("  flags     = %s" % json.dumps((data or {}).get("flags"), ensure_ascii=False))
    except json.JSONDecodeError:
        print("[!] body 不是合法 JSON，跳过解析")
    except Exception as exc:  # noqa: BLE001
        print("[!] 解析事件时出错: %s" % exc)

    print("=" * 70 + "\n")

    # 返回 2xx，平台视为成功并重置失败计数
    return jsonify({"status": "ok"}), 200


if __name__ == "__main__":
    if SECRET:
        print("SECRET: %s" % SECRET.decode("utf-8"))
    else:
        print("SECRET: (未配置，跳过验签；可通过参数或环境变量 WEBHOOK_SECRET 指定)")
    print("监听 0.0.0.0:%d ..." % PORT)
    app.run(host="0.0.0.0", port=PORT, debug=False)
