# -*- coding: utf-8 -*-
"""
薯芯云授权中心 - Python 客户端 SDK
ChipZX Auth Python Client

依赖：requests (pip install requests)

用法：
    from auth_client import AuthClient

    # 多应用模式：第 2 个参数为「应用密钥」，第 3 个参数为接口地址，
    # 第 4 个为超时，第 5 个为应用标识 app_id（控制台 - 应用管理）
    client = AuthClient('你的授权码', '应用密钥', app_id='app_xxxxxxxx')

    if not client.is_valid(domain='example.com'):
        raise SystemExit('授权验证失败')

    # 在线更新检查
    up = client.check_update('1.0.0')
    if up.get('data', {}).get('has_update'):
        print('发现新版本：', up['data']['version']['version'])

版本：2.0.0
"""

import hashlib
import json
import os
import platform
import socket
import sys
import tempfile
import time
import uuid
from typing import Any, Dict, Optional

try:
    import requests
except ImportError:  # 回退到标准库
    requests = None
    import urllib.parse
    import urllib.request
    import ssl


class AuthError(Exception):
    """授权异常"""
    pass


class AuthClient:
    """薯芯云授权客户端"""

    DEFAULT_API = "https://auth.chipzx.com/api/license.php"
    DEFAULT_UPDATE_API = "https://auth.chipzx.com/api/update.php"

    def __init__(self, license_key: str, app_key: str,
                 api_url: str = "", timeout: int = 10,
                 app_id: str = ""):
        """
        :param license_key: 授权码（控制台 - 我的授权）
        :param app_key:     应用密钥（控制台 - 应用管理）
        :param api_url:     验证接口地址
        :param timeout:     请求超时秒数
        :param app_id:      应用标识，多应用模式下建议传入（如 app_xxxxxxxx）
        """
        self.license_key = license_key
        self.app_key = app_key
        self.api_url = api_url or self.DEFAULT_API
        self.update_api = self.DEFAULT_UPDATE_API
        self.timeout = timeout
        self.app_id = app_id
        self._last_response: Dict[str, Any] = {}

    def set_app_id(self, app_id: str) -> "AuthClient":
        """链式设置应用标识"""
        self.app_id = app_id
        return self

    # ------------------------------------------------------------------
    #  核心方法
    # ------------------------------------------------------------------
    def verify(self, domain: str = "", ip: str = "", cache_ttl: int = 0,
               app_id: str = "") -> Dict[str, Any]:
        """
        验证授权

        :param domain:    待验证域名，留空自动取本机主机名
        :param ip:        待验证 IP，留空由服务端取请求来源 IP
        :param cache_ttl: 本地缓存秒数，>0 时启用（减少请求次数）
        :param app_id:    应用标识，覆盖实例默认值
        :return: {'code': '1000', 'status': 1, 'msg': '授权有效', 'data': {...}}
        """
        if cache_ttl > 0:
            cached = self._read_cache(cache_ttl)
            if cached is not None:
                return cached

        params = {
            "license_key": self.license_key,
            "domain": self._normalize_domain(domain or socket.gethostname()),
            "ip": ip or self._local_ip(),
            "timestamp": int(time.time()),
            "nonce": uuid.uuid4().hex[:16],
        }
        aid = app_id or self.app_id
        if aid:
            params["app_id"] = aid
        params["sign"] = self.sign(params)

        result = self._request(params)
        if cache_ttl > 0 and result.get("status") == 1:
            self._write_cache(result)

        self._last_response = result
        return result

    def is_valid(self, domain: str = "", ip: str = "", cache_ttl: int = 0) -> bool:
        """简化判断：授权是否有效"""
        res = self.verify(domain=domain, ip=ip, cache_ttl=cache_ttl)
        return res.get("status") == 1

    def assert_valid(self, **kwargs) -> Dict[str, Any]:
        """验证失败则抛出 AuthError"""
        res = self.verify(**kwargs)
        if res.get("status") != 1:
            raise AuthError(f"[{res.get('code')}] {res.get('msg')}")
        return res

    # ------------------------------------------------------------------
    #  未授权拦截（Web 场景：输出提示页并终止）
    # ------------------------------------------------------------------
    def guard(self, domain: str = "", exit_on_fail: bool = True, **kwargs) -> Dict[str, Any]:
        """
        未授权时输出「该系统未获得授权」HTML 提示页并终止程序

        用法（Flask / Django / 任意 WSGI 入口）：
            client.guard(domain=request.host)

        :param domain:        当前域名
        :param exit_on_fail:  未授权时是否 sys.exit（False 则只返回响应）
        :return: 验证通过返回完整响应
        """
        res = self.verify(domain=domain, **kwargs)
        if res.get("status") == 1:
            return res
        html = self.unauthorized_page(res, domain=domain)
        try:
            sys.stdout.write("Content-Type: text/html; charset=utf-8\r\n")
            sys.stdout.write("Status: 403 Forbidden\r\n\r\n")
            sys.stdout.write(html)
        except Exception:
            print(html)
        if exit_on_fail:
            sys.exit(1)
        return res

    # guard 的别名
    enforce = guard

    @staticmethod
    def unauthorized_page(res: Dict[str, Any], domain: str = "") -> str:
        """渲染未授权提示页 HTML（文案优先取服务端下发的 unauth 配置）"""
        import html as _html

        cfg = res.get("unauth") or {}
        title = cfg.get("title") or "该系统未获得授权"
        notice = cfg.get("notice") or "该系统未获得授权，请前往授权后使用"
        buy_url = cfg.get("buy_url") or "https://auth.chipzx.com/pricing.php"
        brand = cfg.get("brand") or "薯芯云授权中心"
        contact = cfg.get("contact") or ""

        code = res.get("code") or "5001"
        msg = res.get("msg") or "无法连接授权服务器"
        key = (res.get("data") or {}).get("license_key") or ""

        def esc(v) -> str:
            return _html.escape(str(v), quote=True)

        contact_row = ""
        if contact:
            contact_row = (
                '<div class="ua-row"><span>联系方式</span>'
                f"<b>{esc(contact)}</b></div>"
            )

        return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>{esc(title)}</title>
<style>
*{{box-sizing:border-box}}
body{{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
 background:radial-gradient(1100px 520px at 50% -10%,#e8efff 0%,#f6f8fc 45%,#f2f5fa 100%);
 font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
 color:#16233d;padding:24px}}
.ua-card{{width:100%;max-width:520px;background:#fff;border:1px solid #e4eaf5;border-radius:18px;
 box-shadow:0 18px 50px rgba(22,45,90,.10);overflow:hidden}}
.ua-top{{background:linear-gradient(135deg,#1763f5,#3d7bff);color:#fff;padding:26px 28px;text-align:center}}
.ua-badge{{width:58px;height:58px;margin:0 auto 14px;border-radius:16px;background:rgba(255,255,255,.18);
 display:flex;align-items:center;justify-content:center;font-size:30px}}
.ua-top h1{{margin:0;font-size:20px;font-weight:600;letter-spacing:.4px}}
.ua-top p{{margin:8px 0 0;font-size:13.5px;color:rgba(255,255,255,.88);line-height:1.7}}
.ua-body{{padding:22px 28px 26px}}
.ua-alert{{display:flex;gap:10px;background:#fff6f6;border:1px solid #ffd9d9;color:#b3271f;
 border-radius:12px;padding:12px 14px;font-size:14px;line-height:1.7;margin-bottom:18px}}
.ua-row{{display:flex;justify-content:space-between;gap:12px;padding:9px 0;
 border-bottom:1px dashed #e9eef7;font-size:13px}}
.ua-row:last-child{{border-bottom:none}}
.ua-row span{{color:#7b8798;flex:0 0 auto}}
.ua-row b{{font-weight:600;word-break:break-all;text-align:right;font-family:ui-monospace,Menlo,Consolas,monospace}}
.ua-actions{{margin-top:20px;display:flex;gap:10px;flex-wrap:wrap}}
.ua-btn{{flex:1;min-width:150px;text-align:center;text-decoration:none;border-radius:11px;
 padding:12px 18px;font-size:14.5px;font-weight:600}}
.ua-btn.primary{{background:linear-gradient(135deg,#1763f5,#3d7bff);color:#fff;
 box-shadow:0 8px 22px rgba(23,99,245,.28)}}
.ua-btn.ghost{{background:#f2f5fb;color:#3b4a63;border:1px solid #e1e8f4}}
.ua-foot{{margin-top:16px;text-align:center;font-size:12px;color:#98a3b5}}
@media(max-width:480px){{.ua-top{{padding:22px 20px}}.ua-body{{padding:18px 20px 22px}}}}
</style>
</head>
<body>
<div class="ua-card">
  <div class="ua-top">
    <div class="ua-badge">&#128274;</div>
    <h1>{esc(title)}</h1>
    <p>{esc(notice)}</p>
  </div>
  <div class="ua-body">
    <div class="ua-alert"><span>&#9888;&#65039;</span><span><b>{esc(msg)}</b>（错误码 {esc(code)}）</span></div>
    <div class="ua-row"><span>当前域名</span><b>{esc(domain or '-')}</b></div>
    <div class="ua-row"><span>授权码</span><b>{esc(key or '-')}</b></div>
    <div class="ua-row"><span>服务器时间</span><b>{esc(time.strftime('%Y-%m-%d %H:%M:%S'))}</b></div>
    {contact_row}
    <div class="ua-actions">
      <a class="ua-btn primary" href="{esc(buy_url)}" target="_blank" rel="noopener">前往授权</a>
      <a class="ua-btn ghost" href="javascript:location.reload()">重新检测</a>
    </div>
    <div class="ua-foot">{esc(brand)} &middot; 正版授权保护</div>
  </div>
</div>
</body>
</html>"""

    # ------------------------------------------------------------------
    #  在线更新
    # ------------------------------------------------------------------
    def check_update(self, current_version: str, domain: str = "",
                     update_url: str = "", app_id: str = "") -> Dict[str, Any]:
        """
        检查是否有新版本（服务端需已发布版本）

        签名串：app_id, current_version, domain, license_key, nonce, timestamp

        :param current_version: 当前程序版本号，如 '1.0.0'
        :param domain:          当前域名（可选，用于域名绑定校验）
        :param update_url:      更新接口地址，留空使用默认
        :param app_id:          应用标识，覆盖实例默认值
        :return: {'code': '1000', 'status': 1, 'msg': '...', 'data': {...}}
        """
        url = update_url or self.update_api
        params: Dict[str, Any] = {
            "current_version": current_version,
            "license_key": self.license_key,
            "timestamp": int(time.time()),
            "nonce": uuid.uuid4().hex[:16],
        }
        if domain:
            params["domain"] = self._normalize_domain(domain)
        aid = app_id or self.app_id
        if aid:
            params["app_id"] = aid
        params["sign"] = self.sign(params)

        result = self._request(params, url)
        self._last_response = result
        return result

    def latest_version(self, current_version: str, **kwargs) -> Optional[Dict[str, Any]]:
        """便捷方法：有更新时返回 version 字典，否则返回 None"""
        res = self.check_update(current_version, **kwargs)
        data = res.get("data") or {}
        if data.get("has_update"):
            return data.get("version")
        return None

    # ------------------------------------------------------------------
    #  签名
    # ------------------------------------------------------------------
    def sign(self, params: Dict[str, Any]) -> str:
        """
        生成签名
        规则：参数按 key 升序拼接为 k1=v1&k2=v2，末尾拼 &key=APP_KEY，
              MD5 后转大写
        """
        data = {k: v for k, v in params.items() if k != "sign" and v not in ("", None)}
        parts = [f"{k}={data[k]}" for k in sorted(data.keys())]
        raw = "&".join(parts) + "&key=" + self.app_key
        return hashlib.md5(raw.encode("utf-8")).hexdigest().upper()

    # ------------------------------------------------------------------
    #  离线授权码验证
    # ------------------------------------------------------------------
    @staticmethod
    def verify_offline(code: str, public_key_pem: str = "",
                       current_domain: str = "") -> Dict[str, Any]:
        """
        验证离线授权码（RSA-SHA256），无需联网

        :param code:             离线授权码
        :param public_key_pem:   服务器公钥 PEM 内容（留空则读取同目录 public.pem）
        :param current_domain:   当前域名，用于校验绑定
        :return: {'valid': bool, 'payload': dict|None, 'msg': str}
        """
        import base64

        try:
            raw = base64.b64decode(code).decode("utf-8")
            bundle = json.loads(raw)
        except Exception:
            return {"valid": False, "payload": None, "msg": "授权码格式错误"}

        payload = bundle.get("payload")
        signature_b64 = bundle.get("signature")
        if not payload or not signature_b64:
            return {"valid": False, "payload": None, "msg": "授权码内容不完整"}

        if not public_key_pem:
            pub_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "public.pem")
            if not os.path.isfile(pub_path):
                return {"valid": False, "payload": None, "msg": "缺少公钥文件 public.pem"}
            with open(pub_path, "r", encoding="utf-8") as f:
                public_key_pem = f.read()

        try:
            from cryptography.hazmat.primitives import hashes, serialization
            from cryptography.hazmat.primitives.asymmetric import padding

            data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
            pub = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
            pub.verify(base64.b64decode(signature_b64), data,
                       padding.PKCS1v15(), hashes.SHA256())
        except ImportError:
            return {"valid": False, "payload": payload,
                    "msg": "离线验签需安装 cryptography：pip install cryptography"}
        except Exception:
            return {"valid": False, "payload": None, "msg": "签名校验失败，授权码可能被篡改"}

        # 到期校验
        expire_at = payload.get("expire_at")
        if expire_at:
            try:
                if time.strptime(expire_at, "%Y-%m-%d %H:%M:%S") < time.localtime():
                    return {"valid": False, "payload": payload, "msg": "授权已过期"}
            except ValueError:
                pass

        # 域名绑定校验
        if current_domain and payload.get("bind_domain"):
            cur = AuthClient._normalize_domain(current_domain)
            if not AuthClient._match_bind(payload["bind_domain"], cur):
                return {"valid": False, "payload": payload, "msg": "当前域名未授权"}

        return {"valid": True, "payload": payload, "msg": "授权有效"}

    # ------------------------------------------------------------------
    #  内部方法
    # ------------------------------------------------------------------
    def _request(self, params: Dict[str, Any], url: str = "") -> Dict[str, Any]:
        target = url or self.api_url
        if requests is not None:
            try:
                resp = requests.post(
                    target,
                    data=params,
                    timeout=self.timeout,
                    headers={"User-Agent": "ChipZX-Auth-Python-SDK/2.0"},
                )
                return resp.json()
            except requests.exceptions.Timeout:
                return self._err("请求超时，请检查网络")
            except requests.exceptions.RequestException as e:
                return self._err(f"请求失败：{e}")
            except ValueError:
                return self._err("服务器返回数据格式错误")
        else:
            try:
                body = urllib.parse.urlencode(params).encode("utf-8")
                req = urllib.request.Request(
                    target, data=body,
                    headers={
                        "Content-Type": "application/x-www-form-urlencoded",
                        "User-Agent": "ChipZX-Auth-Python-SDK/2.0",
                    },
                )
                ctx = ssl.create_default_context()
                with urllib.request.urlopen(req, timeout=self.timeout, context=ctx) as r:
                    return json.loads(r.read().decode("utf-8"))
            except Exception as e:
                return self._err(f"请求失败：{e}")

    @staticmethod
    def _err(msg: str) -> Dict[str, Any]:
        return {"code": "5001", "status": 0, "msg": msg, "data": None}

    @staticmethod
    def _normalize_domain(domain: str) -> str:
        if not domain:
            return ""
        d = domain.strip().lower()
        for prefix in ("https://", "http://"):
            if d.startswith(prefix):
                d = d[len(prefix):]
        d = d.split("/")[0].split(":")[0]
        return d

    @staticmethod
    def _match_bind(bind_list: str, target: str) -> bool:
        for item in str(bind_list).split(","):
            item = item.strip().lower()
            if not item:
                continue
            if item == target:
                return True
            if item.startswith("*.") and target.endswith(item[1:]):
                return True
        return False

    @staticmethod
    def _local_ip() -> str:
        try:
            s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            s.connect(("8.8.8.8", 80))
            ip = s.getsockname()[0]
            s.close()
            return ip
        except Exception:
            return ""

    # 缓存 --------------------------------------------------------------
    def _cache_file(self) -> str:
        name = hashlib.md5((self.app_id + "|" + self.license_key).encode("utf-8")).hexdigest()
        return os.path.join(tempfile.gettempdir(), f"czx_auth_{name}.cache")

    def _read_cache(self, ttl: int) -> Optional[Dict[str, Any]]:
        path = self._cache_file()
        try:
            if not os.path.isfile(path):
                return None
            if time.time() - os.path.getmtime(path) > ttl:
                return None
            with open(path, "r", encoding="utf-8") as f:
                return json.load(f)
        except Exception:
            return None

    def _write_cache(self, data: Dict[str, Any]) -> None:
        try:
            with open(self._cache_file(), "w", encoding="utf-8") as f:
                json.dump(data, f, ensure_ascii=False)
        except Exception:
            pass

    @property
    def last_response(self) -> Dict[str, Any]:
        return self._last_response
