# -*- coding: utf-8 -*-
"""
five44_client.py — 5.44 统一管理平台代理客户端（服务授权模块阶段二）

从目标服务器（5.44）的 http_client.py 逆向移植的认证 + 签名算法：
  1. 登录前密码 SHA256 摘要（与前端一致）
  2. 请求签名：x_random + x_timestamp + JSON(body) → SHA256 → AES-CBC(PKCS7)
     - 有 token 时：AES 密钥源 = SHA256("Bearer {token}")
     - 无 token 时：AES 密钥源 = SHA256(60 位随机串)，该串随 RandomCode 头发出
  3. AES 密钥/IV 均按 UTF-8 字节解析（与 CryptoJS 传字符串行为一致），非 hex

职责：封装 5.44 全部 6 个授权相关接口，自动登录 + 缓存 token。

注意：
  - 本客户端仅用于服务授权代理，与服务监测模块（service_monitor）完全独立。
  - 依赖 requests + pycryptodome（pycryptodome 缺失时签名直接失败并给出明确错误，
    不静默降级——代理模式必须真实验签，否则 5.44 一律返回"无效token"）。
"""

from __future__ import annotations

import base64
import hashlib
import json
import logging
import random
import string
import time
from typing import Any, Dict, Optional, Tuple

import requests

try:
    from Crypto.Cipher import AES
    from Crypto.Util.Padding import pad
    HAS_CRYPTO = True
except ImportError:  # pragma: no cover - 依赖缺失路径
    HAS_CRYPTO = False

logger = logging.getLogger("service_manage.five44")

# 5.44 固定验证码旁路（安全测试已知项）
DEFAULT_CAPTCHA = "csba"
# 下载激活文件的产品类别参数
LICENSE_CATEGORY = "java_python"


class Five44Error(Exception):
    """5.44 平台交互错误（登录失败/请求失败/服务端业务错误）。"""


class Five44Client:
    """5.44 统一管理平台 API 客户端（认证 + 签名 + 业务调用）。"""

    def __init__(
        self,
        base_url: str,
        username: str,
        password: str,
        captcha: str = DEFAULT_CAPTCHA,
        verify_ssl: bool = False,
        timeout: int = 30,
    ) -> None:
        self.base_url = base_url.rstrip("/")
        self.username = username
        self.password = password
        self.captcha = captcha
        self.verify_ssl = verify_ssl
        self.timeout = timeout

        self.session = requests.Session()
        self._token: Optional[str] = None

    # ============================================================
    # 签名基础
    # ============================================================

    @staticmethod
    def encrypt_password(password: str) -> str:
        """登录密码 SHA256 摘要（与前端一致）。"""
        return hashlib.sha256(password.encode("utf-8")).hexdigest()

    @staticmethod
    def _sha256(text: str) -> str:
        return hashlib.sha256(text.encode("utf-8")).hexdigest()

    @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 _aes_encrypt(plaintext: str, key_str: str, iv_str: str) -> str:
        """AES-CBC PKCS7 加密（CryptoJS 字符串密钥按 UTF-8 编码，非 hex）。"""
        if not HAS_CRYPTO:
            raise Five44Error("pycryptodome 未安装，无法进行请求签名（pip install pycryptodome）")
        key_bytes = key_str.encode("utf-8")   # 16 字节
        iv_bytes = iv_str.encode("utf-8")     # 16 字节
        cipher = AES.new(key_bytes, AES.MODE_CBC, iv_bytes)
        padded = pad(plaintext.encode("utf-8"), AES.block_size)
        return base64.b64encode(cipher.encrypt(padded)).decode("utf-8")

    def _generate_sign(
        self,
        body_data: Optional[dict] = None,
        bearer_token: str = "",
    ) -> Dict[str, str]:
        """生成签名头 {X-RANDOM, X-TIMESTAMP, X-SIGN, [RandomCode]}。"""
        x_random = self._generate_random_string(8, 16)
        x_timestamp = str(int(time.time() * 1000))

        if body_data is not None:
            sign_str = (
                x_timestamp
                + json.dumps(body_data, separators=(",", ":"), ensure_ascii=False)
                + x_random
            )
        else:
            sign_str = x_timestamp + x_random
        sign_hash = self._sha256(sign_str)

        random_code = None
        if bearer_token:
            aes_key_source = self._sha256(bearer_token)
        else:
            random_code = self._generate_random_string(60, 60)
            aes_key_source = self._sha256(random_code)

        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)

        headers = {
            "X-RANDOM": x_random,
            "X-TIMESTAMP": x_timestamp,
            "X-SIGN": x_sign,
        }
        if random_code:
            headers["RandomCode"] = random_code
        return headers

    # ============================================================
    # 登录 / token 管理
    # ============================================================

    def get_captcha_uuid(self) -> str:
        """获取验证码 UUID（GET /platform/api/code）。"""
        url = f"{self.base_url}/platform/api/code"
        try:
            resp = self.session.get(url, verify=self.verify_ssl, timeout=self.timeout)
            data = resp.json()
        except Exception as e:
            raise Five44Error(f"获取验证码失败: {e}") from e
        uuid_val = (
            data.get("uuid")
            or (data.get("data") or {}).get("uuid")
            or ""
        )
        return str(uuid_val)

    def login(self) -> str:
        """登录 5.44 平台，返回 accessToken，失败抛 Five44Error。"""
        uuid_val = self.get_captcha_uuid()
        payload = {
            "username": self.username,
            "password": self.encrypt_password(self.password),
            "code": self.captcha,
            "uuid": uuid_val,
        }
        headers = {
            "Content-Type": "application/json",
            "Accept": "application/json, text/plain, */*",
        }
        headers.update(self._generate_sign(body_data=payload, bearer_token=""))

        try:
            resp = self.session.post(
                f"{self.base_url}/platform/api/auth/login",
                json=payload,
                headers=headers,
                verify=self.verify_ssl,
                timeout=self.timeout,
            )
        except requests.RequestException as e:
            raise Five44Error(f"登录请求异常: {e}") from e

        try:
            data = resp.json()
        except ValueError:
            raise Five44Error(f"登录响应非 JSON（HTTP {resp.status_code}）") from None

        token = (
            data.get("accessToken")
            or data.get("token")
            or data.get("access_token")
            or (data.get("data") or {}).get("accessToken")
            or (data.get("data") or {}).get("token")
            or (data.get("data") or {}).get("access_token")
        )
        if not token:
            msg = data.get("msg") or data.get("message") or "未知原因"
            raise Five44Error(f"5.44 登录失败: {msg}")

        self._token = str(token)
        logger.info("5.44 登录成功: %s", self.username)
        return self._token

    def ensure_token(self) -> str:
        """获取有效 token（已登录直接返回，否则重新登录）。"""
        return self._token or self.login()

    def clear_token(self) -> None:
        self._token = None

    # ============================================================
    # 带签名请求
    # ============================================================

    def _request(
        self,
        method: str,
        path: str,
        json_data: Optional[dict] = None,
        params: Optional[dict] = None,
        files: Optional[dict] = None,
    ) -> requests.Response:
        """发送带签名请求；遇到 401/无效token 自动重新登录重试一次。"""
        token = self.ensure_token()
        bearer = f"Bearer {token}"

        body_for_sign = None
        if method.lower() in ("post", "put", "patch") and json_data is not None:
            body_for_sign = json_data
        elif method.lower() in ("get", "delete") and params:
            body_for_sign = params

        headers = {
            "Accept": "application/json, text/plain, */*",
        }
        if json_data is not None:
            headers["Content-Type"] = "application/json"
        headers.update(self._generate_sign(body_data=body_for_sign, bearer_token=bearer))
        if files is None:
            headers["Authorization"] = bearer

        url = f"{self.base_url}{path}"
        try:
            resp = self.session.request(
                method, url,
                json=json_data if json_data is not None else None,
                params=params if params is not None else None,
                files=files,
                headers=headers,
                verify=self.verify_ssl,
                timeout=self.timeout,
            )
        except requests.RequestException as e:
            raise Five44Error(f"请求 5.44 异常 [{path}]: {e}") from e

        # token 失效时重登重试一次
        if resp.status_code in (401, 403) and self._token:
            self.clear_token()
            token = self.login()
            bearer = f"Bearer {token}"
            headers["Authorization"] = bearer
            headers.update(self._generate_sign(body_data=body_for_sign, bearer_token=bearer))
            try:
                resp = self.session.request(
                    method, url,
                    json=json_data if json_data is not None else None,
                    params=params if params is not None else None,
                    files=files,
                    headers=headers,
                    verify=self.verify_ssl,
                    timeout=self.timeout,
                )
            except requests.RequestException as e:
                raise Five44Error(f"重试请求 5.44 异常 [{path}]: {e}") from e

        return resp

    def _check_ok(self, resp: requests.Response, path: str) -> Dict[str, Any]:
        """解析 JSON 响应，非成功时抛 Five44Error。"""
        try:
            data = resp.json()
        except ValueError:
            raise Five44Error(
                f"5.44 返回非 JSON [{path}]：HTTP {resp.status_code}"
            ) from None
        if not isinstance(data, dict):
            raise Five44Error(f"5.44 返回格式异常 [{path}]")
        # 业务失败判断：code 非 200/0/success
        code = data.get("code", 200)
        if resp.status_code >= 400 or (isinstance(code, int) and code not in (0, 200)):
            msg = data.get("msg") or data.get("message") or f"HTTP {resp.status_code}"
            raise Five44Error(f"5.44 业务失败 [{path}]: {msg}")
        if data.get("success") is False:
            msg = data.get("message") or data.get("msg") or "未知原因"
            raise Five44Error(f"5.44 业务失败 [{path}]: {msg}")
        return data

    # ============================================================
    # 业务接口（6 个）
    # ============================================================

    def download_activation(self) -> Tuple[bytes, str]:
        """下载激活文件（POST /exapi/system/downLincenceFile?category=java_python）。

        Returns: (文件字节, 服务端文件名)
        """
        path = f"/exapi/system/downLincenceFile?category={LICENSE_CATEGORY}"
        resp = self._request("GET", path)
        if resp.status_code >= 400:
            raise Five44Error(
                f"下载激活文件失败：HTTP {resp.status_code}"
            )
        return resp.content, self.download_name(resp, "licences.zip")

    def upload_license(self, file_bytes: bytes, filename: str) -> Dict[str, Any]:
        """上传授权文件（POST /backstage/exapi/system/uploadLincenceFile，multipart）。"""
        path = "/backstage/exapi/system/uploadLincenceFile"
        resp = self._request(
            "POST",
            path,
            files={"file": (filename, file_bytes)},
        )
        if resp.status_code >= 400:
            raise Five44Error(f"上传授权文件失败：HTTP {resp.status_code}")
        return self._check_ok(resp, path)

    def upload_deploy_view(self, file_bytes: bytes, filename: str) -> Dict[str, Any]:
        """上传部署视图（POST /exapi/uploader/uploadDeployView，multipart）。"""
        path = "/exapi/uploader/uploadDeployView"
        resp = self._request(
            "POST",
            path,
            files={"file": (filename, file_bytes)},
        )
        if resp.status_code >= 400:
            raise Five44Error(f"上传部署视图失败：HTTP {resp.status_code}")
        return self._check_ok(resp, path)

    def upload_self_check(self, file_bytes: bytes, filename: str) -> Dict[str, Any]:
        """上传自检文档（POST /exapi/uploader/uploadServiceDetect，multipart）。"""
        path = "/exapi/uploader/uploadServiceDetect"
        resp = self._request(
            "POST",
            path,
            files={"file": (filename, file_bytes)},
        )
        if resp.status_code >= 400:
            raise Five44Error(f"上传自检文档失败：HTTP {resp.status_code}")
        return self._check_ok(resp, path)

    def check_task_status(self, task_id: str) -> Dict[str, Any]:
        """查询授权任务状态（GET /backstage/exapi/system/licenceTaskStatus?taskId=xxx）。"""
        path = "/backstage/exapi/system/licenceTaskStatus"
        resp = self._request("GET", path, params={"taskId": task_id})
        if resp.status_code >= 400:
            raise Five44Error(f"查询授权任务状态失败：HTTP {resp.status_code}")
        return self._check_ok(resp, path)

    def save_project(self, data: dict) -> Dict[str, Any]:
        """保存项目信息（POST /exapi/system/setOtherInfo）。"""
        path = "/exapi/system/setOtherInfo"
        resp = self._request("POST", path, json_data=data)
        if resp.status_code >= 400:
            raise Five44Error(f"保存项目信息失败：HTTP {resp.status_code}")
        return self._check_ok(resp, path)

    def download_deploy_view(self) -> Tuple[bytes, str]:
        """导出部署视图（GET /exapi/system/exportDeployView）。"""
        path = "/exapi/system/exportDeployView"
        resp = self._request("GET", path)
        if resp.status_code >= 400:
            raise Five44Error(f"导出部署视图失败：HTTP {resp.status_code}")
        return resp.content, self.download_name(resp, "deploymentView.xlsx")

    def download_self_check(self) -> Tuple[bytes, str]:
        """导出自检文档（GET /exapi/system/exportServiceDetect）。"""
        path = "/exapi/system/exportServiceDetect"
        resp = self._request("GET", path)
        if resp.status_code >= 400:
            raise Five44Error(f"导出自检文档失败：HTTP {resp.status_code}")
        return resp.content, self._download_name(resp, "checkList.xlsx")

    @staticmethod
    def download_name(resp: requests.Response, default: str) -> str:
        """从 Content-Disposition 解析文件名，解析失败用默认名。"""
        import re

        disposition = resp.headers.get("Content-Disposition") or ""
        match = re.search(
            r"filename\*\s*=\s*UTF-8''([^;]+)|filename\s*=\s*\"?([^\";\s]+)\"?",
            disposition,
        )
        if match:
            name = (match.group(1) or match.group(2) or "").strip()
            if name:
                return name
        return default