<?php
declare(strict_types=1);

/**
 * 通用 HTTP 客户端，封装 cURL，支持三类用途：
 *
 *  JSON API   — get() / post() / postForm() → array（自动合并 baseParams、解析 JSON）
 *               post() 发 JSON body，postForm() 发表单编码；对端读 $_POST 的要用后者
 *  抓取 HTML  — fetchHtml()             → string（原始 HTML/文本，不做解析）
 *  下载文件   — download()              → string（保存到本地，返回实际写入路径）
 *
 * 构造参数：
 *   $baseUrl    — API 根地址，get()/post() 拼相对路径用；fetchHtml()/download() 传完整 URL，此项可为空
 *   $baseParams — 固定参数（token 等），get()/post() 自动合并；fetchHtml()/download() 不合并
 *   $timeout    — 全局超时秒数，download() 可单独覆盖
 */
class HttpClient
{
    private string $baseUrl;
    private array  $baseParams;
    private int    $timeout;
    private bool   $sslVerify;

    /**
     * @param string $baseUrl    API 根地址
     * @param array  $baseParams 固定参数（token 等）
     * @param int    $timeout    全局超时秒数
     * @param bool   $sslVerify  是否验证 SSL 证书；Windows 本地开发可设 false，生产环境保持 true
     */
    public function __construct(string $baseUrl, array $baseParams = [], int $timeout = 120, bool $sslVerify = true)
    {
        $this->baseUrl    = rtrim($baseUrl, '/');
        $this->baseParams = $baseParams;
        $this->timeout    = $timeout;
        $this->sslVerify  = $sslVerify;
    }

    // ── JSON API ──────────────────────────────────────────────────────────────

    /**
     * GET 请求，业务参数与 baseParams 合并后拼入 query string，响应解析为 array
     * @throws \RuntimeException
     */
    public function get(string $path, array $params = []): array
    {
        $query = array_merge($this->baseParams, $params);
        $url   = $this->baseUrl . '/' . ltrim($path, '/');
        if ($query) {
            $url .= '?' . http_build_query($query);
        }
        return $this->parseJson($this->exec($url));
    }

    /**
     * POST 请求（JSON body），业务参数与 baseParams 合并后作为 JSON body 发送，响应解析为 array
     * @throws \RuntimeException
     */
    public function post(string $path, array $params = []): array
    {
        $body = array_merge($this->baseParams, $params);
        $url  = $this->baseUrl . '/' . ltrim($path, '/');
        return $this->parseJson($this->execPost($url, $body));
    }

    /**
     * POST 请求（表单编码 application/x-www-form-urlencoded），业务参数与 baseParams 合并后作为表单字段发送
     * 对端用 $_POST 读参数时必须走这个方法，post() 发的 JSON body 填不进 $_POST
     * @throws \RuntimeException
     */
    public function postForm(string $path, array $params = []): array
    {
        $body = array_merge($this->baseParams, $params);
        $url  = $this->baseUrl . '/' . ltrim($path, '/');
        return $this->parseJson($this->execPostForm($url, $body));
    }

    // ── HTML 抓取 ─────────────────────────────────────────────────────────────

    /**
     * 获取任意 URL 的 HTML/文本内容，不合并 baseParams
     * @param string   $url      完整 URL
     * @param string[] $headers  额外 HTTP 请求头，如 ['Accept-Language: zh-CN']
     * @return string            原始响应体
     * @throws \RuntimeException
     */
    public function fetchHtml(string $url, array $headers = []): string
    {
        $opts = [
            CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; HttpClient/1.0)',
        ];
        if ($headers) {
            $opts[CURLOPT_HTTPHEADER] = $headers;
        }
        return $this->exec($url, $opts);
    }

    // ── 文件下载 ──────────────────────────────────────────────────────────────

    /**
     * 下载远程文件到本地路径
     *
     * @param string $url      完整的文件 URL（图片、PDF、ZIP 等）
     * @param string $savePath 保存的本地绝对路径（含文件名），目录须已存在
     * @param int    $timeout  下载超时秒数，默认 60（覆盖全局 timeout）
     * @return string          实际写入的路径（即 $savePath）
     * @throws \RuntimeException cURL 错误或写文件失败时抛出
     */
    public function download(string $url, string $savePath, int $timeout = 300): string
    {
        $fh = fopen($savePath, 'wb');
        if ($fh === false) {
            throw new \RuntimeException("HttpClient: 无法打开写入文件 {$savePath}");
        }

        $ch = $this->baseCurl($url, $timeout);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
        curl_setopt($ch, CURLOPT_FILE, $fh);

        curl_exec($ch);
        $errno = curl_errno($ch);
        $error = curl_error($ch);
        curl_close($ch);
        fclose($fh);

        if ($errno !== 0) {
            @unlink($savePath);
            throw new \RuntimeException("HttpClient download cURL error [{$errno}]: {$error}");
        }

        return $savePath;
    }

    // ── 内部工具 ──────────────────────────────────────────────────────────────

    private function baseCurl(string $url, ?int $timeout = null): \CurlHandle
    {
        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL            => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_ENCODING       => '',   // 自动解压 gzip/deflate
            CURLOPT_TIMEOUT        => $timeout ?? $this->timeout,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_SSL_VERIFYPEER => $this->sslVerify,
            CURLOPT_SSL_VERIFYHOST => $this->sslVerify ? 2 : 0,
        ]);
   
        return $ch;
    }

    /** GET 执行，返回原始响应字符串 */
    private function exec(string $url, array $extraOpts = []): string
    {
        $ch = $this->baseCurl($url);
        if ($extraOpts) {
            curl_setopt_array($ch, $extraOpts);
        }

        $raw   = curl_exec($ch);
        $errno = curl_errno($ch);
        $error = curl_error($ch);
        curl_close($ch);

        if ($errno !== 0 || $raw === false) {
            throw new \RuntimeException("HttpClient cURL error [{$errno}]: {$error}");
        }
        return (string) $raw;
    }

    /** POST JSON 执行，返回原始响应字符串 */
    private function execPost(string $url, array $body): string
    {
        $ch   = $this->baseCurl($url);
        $json = json_encode($body, JSON_UNESCAPED_UNICODE);
        curl_setopt_array($ch, [
            CURLOPT_POST       => true,
            CURLOPT_POSTFIELDS => $json,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json',
                'Content-Length: ' . strlen($json),
            ],
        ]);

        $raw   = curl_exec($ch);
        $errno = curl_errno($ch);
        $error = curl_error($ch);
        curl_close($ch);
        if ($errno !== 0 || $raw === false) {
            throw new \RuntimeException("HttpClient cURL error [{$errno}]: {$error}");
        }
        return (string) $raw;
    }

    /** POST 表单编码执行，返回原始响应字符串 */
    private function execPostForm(string $url, array $body): string
    {
        $ch = $this->baseCurl($url);
        curl_setopt_array($ch, [
            CURLOPT_POST       => true,
            CURLOPT_POSTFIELDS => http_build_query($body),
            CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
        ]);

        $raw   = curl_exec($ch);
        $errno = curl_errno($ch);
        $error = curl_error($ch);
        curl_close($ch);
        if ($errno !== 0 || $raw === false) {
            throw new \RuntimeException("HttpClient cURL error [{$errno}]: {$error}");
        }
        return (string) $raw;
    }

    private function parseJson(string $raw): array
    {
        $decoded = json_decode($raw, true);
        if (!is_array($decoded)) {
            throw new \RuntimeException(
                'HttpClient: 响应不是合法的 JSON 对象/数组。原始内容：' . mb_substr($raw, 0, 200)
            );
        }
        return $decoded;
    }
}
