#!/usr/bin/env python3
"""Small Tianyi Cloud OpenAPI runner for repeatable ECS/EIP provisioning.

The script is intentionally dry-run by default. Add --execute only after the
config has been checked against the OpenAPI console.
"""

from __future__ import annotations

import argparse
import base64
import datetime as dt
import hashlib
import hmac
import json
import os
import ssl
import subprocess
import sys
import uuid
from dataclasses import dataclass
from typing import Any
from urllib import parse, request
from urllib.error import URLError


SSL_CERTIFICATE_HINT = "当前网络证书校验失败，可能为网吧/代理/杀软 HTTPS 拦截，请更换网络/手机热点/自有代理后重试"


class CtyunSslCertificateError(RuntimeError):
    """Raised when HTTPS certificate validation fails before CTYUN can answer."""


def _certifi_cafile() -> str | None:
    try:
        import certifi  # type: ignore

        path = certifi.where()
    except Exception:
        return None
    return path if path and os.path.exists(path) else None


def ctyun_ca_bundle() -> str | None:
    for name in ("CTYUN_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"):
        path = os.environ.get(name, "").strip()
        if path and os.path.exists(path):
            return path
    return _certifi_cafile()


def ctyun_proxy_url() -> str:
    for name in ("CTYUN_HTTPS_PROXY", "CTYUN_PROXY", "HTTPS_PROXY", "HTTP_PROXY"):
        value = os.environ.get(name, "").strip()
        if value:
            return value
    return ""


def _ctyun_ssl_context() -> ssl.SSLContext:
    cafile = ctyun_ca_bundle()
    if cafile:
        return ssl.create_default_context(cafile=cafile)
    return ssl.create_default_context()


def _ctyun_opener() -> request.OpenerDirector:
    handlers: list[Any] = [request.HTTPSHandler(context=_ctyun_ssl_context())]
    proxy = ctyun_proxy_url()
    if os.environ.get("CTYUN_HTTPS_PROXY") or os.environ.get("CTYUN_PROXY"):
        handlers.append(request.ProxyHandler({"http": proxy, "https": proxy}))
    return request.build_opener(*handlers)


def is_ctyun_ssl_certificate_error(exc: BaseException | str) -> bool:
    text = str(exc).lower()
    return any(
        token in text
        for token in (
            "certificate_verify_failed",
            "certificate verify failed",
            "unable to get local issuer certificate",
            "sslcertverificationerror",
        )
    )


def ctyun_ssl_failure_message(exc: BaseException | str) -> str:
    return f"{SSL_CERTIFICATE_HINT}。原始错误：{exc}"


def ctyun_urlopen(req: request.Request, *, timeout: int):
    try:
        return _ctyun_opener().open(req, timeout=timeout)
    except Exception as exc:
        if is_ctyun_ssl_certificate_error(exc):
            raise CtyunSslCertificateError(ctyun_ssl_failure_message(exc)) from exc
        raise


def check_stop_requested() -> None:
    stop_file = os.environ.get("CTYUN_WORKFLOW_STOP_FILE", "")
    if stop_file and os.path.exists(stop_file):
        raise SystemExit("用户已点击停止，当前任务已停止。")


def hidden_subprocess_kwargs() -> dict[str, Any]:
    if os.name != "nt":
        return {}
    startupinfo = subprocess.STARTUPINFO()
    startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
    startupinfo.wShowWindow = 0
    return {
        "startupinfo": startupinfo,
        "creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0),
    }


def _json_dumps(data: Any) -> str:
    return json.dumps(data if data is not None else {}, ensure_ascii=False)


def _hmac_sha256(secret: bytes | str, data: bytes | str) -> bytes:
    if isinstance(secret, str):
        secret = secret.encode("utf-8")
    if isinstance(data, str):
        data = data.encode("utf-8")
    return hmac.new(secret, data, digestmod=hashlib.sha256).digest()


def _query_string(params: dict[str, Any]) -> str:
    pairs: list[str] = []
    for key, value in sorted((params or {}).items()):
        if value is None:
            value = ""
        pairs.append(f"{key}={parse.quote(str(value), safe='')}")
    return "&".join(pairs)


@dataclass
class CtyunClient:
    ak: str
    sk: str
    endpoint: str
    dry_run: bool = True
    timeout: int = 20

    def signed_headers(self, query: dict[str, Any], body: dict[str, Any] | None) -> dict[str, str]:
        eop_date = dt.datetime.now().strftime("%Y%m%dT%H%M%SZ")
        request_id = str(uuid.uuid1())
        if body is not None:
            body_text = _json_dumps(body)
            body_digest = hashlib.sha256(body_text.encode("utf-8")).hexdigest()
        else:
            body_digest = hashlib.sha256(b"").hexdigest()
        header_text = f"ctyun-eop-request-id:{request_id}\neop-date:{eop_date}\n"
        signature_text = f"{header_text}\n{_query_string(query)}\n{body_digest}"

        sign_date = eop_date.split("T", 1)[0]
        k_time = _hmac_sha256(self.sk, eop_date)
        k_ak = _hmac_sha256(k_time, self.ak)
        k_date = _hmac_sha256(k_ak, sign_date)
        signature = base64.b64encode(_hmac_sha256(k_date, signature_text)).decode("utf-8")

        return {
            "Content-type": "application/json;charset=UTF-8",
            "User-Agent": "Mozilla/5.0(pysdk)",
            "ctyun-eop-request-id": request_id,
            "Eop-date": eop_date,
            "Eop-Authorization": (
                f"{self.ak} Headers=ctyun-eop-request-id;eop-date Signature={signature}"
            ),
        }

    def post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
        url = self.endpoint.rstrip("/") + path
        check_stop_requested()
        if self.dry_run:
            print(f"\nDRY-RUN POST {url}")
            print(json.dumps(body, ensure_ascii=False, indent=2))
            return {"dryRun": True, "url": url, "body": body}

        payload = _json_dumps(body).encode("utf-8")
        headers = self.signed_headers(query={}, body=body)
        req = request.Request(url, data=payload, headers=headers, method="POST")
        with ctyun_urlopen(req, timeout=self.timeout) as resp:
            text = resp.read().decode("utf-8")
        try:
            return json.loads(text)
        except json.JSONDecodeError:
            return {"raw": text}

    def delete(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
        url = self.endpoint.rstrip("/") + path
        check_stop_requested()
        if self.dry_run:
            print(f"\nDRY-RUN DELETE {url}")
            print(json.dumps(body, ensure_ascii=False, indent=2))
            return {"dryRun": True, "url": url, "body": body}

        payload = _json_dumps(body).encode("utf-8")
        headers = self.signed_headers(query={}, body=body)
        req = request.Request(url, data=payload, headers=headers, method="DELETE")
        with ctyun_urlopen(req, timeout=self.timeout) as resp:
            text = resp.read().decode("utf-8")
        try:
            return json.loads(text)
        except json.JSONDecodeError:
            return {"raw": text}

    def get(self, path: str, query: dict[str, Any] | None = None) -> dict[str, Any]:
        query = query or {}
        url = self.endpoint.rstrip("/") + path
        query_text = _query_string(query)
        if query_text:
            url = f"{url}?{query_text}"
        check_stop_requested()
        if self.dry_run:
            print(f"\nDRY-RUN GET {url}")
            return {"dryRun": True, "url": url, "query": query}

        headers = self.signed_headers(query=query, body=None)
        req = request.Request(url, headers=headers, method="GET")
        with ctyun_urlopen(req, timeout=self.timeout) as resp:
            text = resp.read().decode("utf-8")
        try:
            return json.loads(text)
        except json.JSONDecodeError:
            return {"raw": text}


def read_user_env(name: str) -> str | None:
    if os.name != "nt":
        return None
    try:
        output = subprocess.check_output(
            [
                "powershell",
                "-NoProfile",
                "-Command",
                f"[Environment]::GetEnvironmentVariable('{name}','User')",
            ],
            text=True,
            timeout=10,
            **hidden_subprocess_kwargs(),
        ).strip()
        return output or None
    except Exception:
        return None


def load_credentials(required: bool = False) -> tuple[str, str]:
    ak = os.environ.get("CTYUN_ACCESS_KEY") or read_user_env("CTYUN_ACCESS_KEY")
    sk = os.environ.get("CTYUN_SECRET_KEY") or read_user_env("CTYUN_SECRET_KEY")
    if required and (not ak or not sk):
        raise SystemExit("请先设置 CTYUN_ACCESS_KEY 和 CTYUN_SECRET_KEY，或设置为 Windows 用户级环境变量。")
    return ak or "DRY_RUN_ACCESS_KEY", sk or "DRY_RUN_SECRET_KEY"


def ecs_batch_body(cfg: dict[str, Any]) -> dict[str, Any]:
    ecs = cfg["ecs"]
    common = cfg["common"]
    network_card = {"subnetID": common["subnetID"], "isMaster": True}
    fixed_ip = ecs.get("fixedIP") or common.get("fixedIP") or common.get("ecsPrivateIP")
    if fixed_ip:
        network_card["fixedIP"] = fixed_ip
    body = {
        "clientToken": str(uuid.uuid4()),
        "regionID": common["regionID"],
        "azName": common["azName"],
        "projectID": common.get("projectID", "0"),
        "instanceName": ecs.get("instanceName", f"{common['regionName']}-ecm"),
        "displayName": ecs.get("displayName", f"{common['regionName']}-ecm"),
        "flavorID": ecs["flavorID"],
        "imageType": ecs.get("imageType", 1),
        "imageID": ecs["imageID"],
        "bootDiskType": ecs.get("bootDiskType", "SATA"),
        "bootDiskSize": ecs.get("bootDiskSize", 40),
        "onDemand": ecs.get("onDemand", True),
        "orderCount": ecs.get("orderCount", 10),
        "vpcID": common["vpcID"],
        "extIP": ecs.get("extIP", "0"),
        "networkCardList": [network_card],
    }
    if ecs.get("securityGroupID"):
        body["secGroupList"] = [ecs["securityGroupID"]]
    elif ecs.get("secGroupList"):
        body["secGroupList"] = ecs["secGroupList"]
    if ecs.get("password"):
        body["userPassword"] = ecs["password"]
        body["loginMode"] = "password"
    body.update(ecs.get("extra", {}))
    return body


def eip_create_body(cfg: dict[str, Any], index: int) -> dict[str, Any]:
    eip = cfg["eip"]
    common = cfg["common"]
    cycle_type = eip.get("cycleType", "on_demand")
    body = {
        "clientToken": str(uuid.uuid4()),
        "regionID": common["regionID"],
        "projectID": common.get("projectID", "0"),
        "cycleType": cycle_type,
        "name": f"{eip.get('namePrefix', common['regionName'] + '-eip')}-{index:02d}",
        "bandwidth": eip.get("bandwidth", 300),
    }
    if eip.get("cycleCount") is not None:
        body["cycleCount"] = eip.get("cycleCount")
    if str(cycle_type).strip().lower() == "on_demand":
        body["demandBillingType"] = eip.get("demandBillingType", "upflowc")
    body.update(eip.get("extra", {}))
    return body


def vip_plan(cfg: dict[str, Any]) -> list[dict[str, Any]]:
    vip = cfg.get("vip", {})
    start = int(vip.get("start", 11))
    end = int(vip.get("end", 19))
    prefix = vip.get("prefix", "10.0.0.")
    return [
        {
            "ipAddress": f"{prefix}{i}",
            "subnetID": cfg["common"]["subnetID"],
            "mode": "manual",
            "note": "Fill the concrete VIP/bind endpoint names from the OpenAPI console.",
        }
        for i in range(start, end + 1)
    ]


def run(cfg: dict[str, Any], execute: bool) -> None:
    ak, sk = load_credentials(required=execute)

    endpoints = cfg.get("endpoints", {})
    dry_run = not execute

    if cfg.get("ecs", {}).get("enabled", True):
        ecs_client = CtyunClient(ak, sk, endpoints["ecs"], dry_run=dry_run)
        print("\n== 批量创建云主机 ==")
        print(json.dumps(ecs_client.post("/v4/ecs/batch-create-instances", ecs_batch_body(cfg)), ensure_ascii=False, indent=2))

    if cfg.get("eip", {}).get("enabled", True):
        eip_client = CtyunClient(ak, sk, endpoints["eip"], dry_run=dry_run)
        count = int(cfg["eip"].get("count", 10))
        print("\n== 创建弹性公网IP ==")
        for i in range(1, count + 1):
            print(json.dumps(eip_client.post("/v4/eip/create", eip_create_body(cfg, i)), ensure_ascii=False, indent=2))

    if cfg.get("vip", {}).get("enabled", False):
        print("\n== 虚拟IP计划 ==")
        print(json.dumps(vip_plan(cfg), ensure_ascii=False, indent=2))


def main() -> int:
    parser = argparse.ArgumentParser(description="Tianyi Cloud OpenAPI provisioning runner")
    parser.add_argument("--config", required=True, help="Path to JSON config")
    mode = parser.add_mutually_exclusive_group()
    mode.add_argument("--dry-run", action="store_true", help="Print API calls only")
    mode.add_argument("--execute", action="store_true", help="Send real OpenAPI requests")
    args = parser.parse_args()

    with open(args.config, "r", encoding="utf-8") as f:
        cfg = json.load(f)
    run(cfg, execute=args.execute)
    return 0


if __name__ == "__main__":
    sys.exit(main())
