<?php

namespace app\lib\client;

use Exception;

/**
 * AWS
 */
class AWS
{
    private $AccessKeyId;
    private $SecretAccessKey;
    private $endpoint;
    private $service;
    private $version;
    private $region;
    private $etag;
    private $proxy = false;

    public function __construct($AccessKeyId, $SecretAccessKey, $endpoint, $service, $version, $region, $proxy = false)
    {
        $this->AccessKeyId = $AccessKeyId;
        $this->SecretAccessKey = $SecretAccessKey;
        $this->endpoint = $endpoint;
        $this->service = $service;
        $this->version = $version;
        $this->region = $region;
        $this->proxy = $proxy;
    }

    /**
     * @param string $method 请求方法
     * @param string $action 方法名称
     * @param array $params 请求参数
     * @return array
     * @throws Exception
     */
    public function request($method, $action, $params = [])
    {
        if (!empty($params)) {
            $params = array_filter($params, function ($a) {
                return $a !== null;
            });
        }

        $body = '';
        $query = [];
        if ($method == 'GET' || $method == 'DELETE') {
            $query = $params;
        } else {
            $body = !empty($params) ? json_encode($params) : '';
        }

        $time = time();
        $date = gmdate("Ymd\THis\Z", $time);
        $headers = [
            'Host' => $this->endpoint,
            'X-Amz-Target' => $action,
            'X-Amz-Date' => $date,
            //'X-Amz-Content-Sha256' => hash("sha256", $body),
        ];
        if ($body) {
            $headers['Content-Type'] = 'application/x-amz-json-1.1';
        }
        $path = '/';

        $authorization = $this->generateSign($method, $path, $query, $headers, $body, $date);
        $headers['Authorization'] = $authorization;

        $url = 'https://' . $this->endpoint . $path;
        if (!empty($query)) {
            $url .= '?' . http_build_query($query);
        }
        $header = [];
        foreach ($headers as $key => $value) {
            $header[] = $key . ': ' . $value;
        }
        return $this->curl($method, $url, $body, $header);
    }

    /**
     * @param string $method 请求方法
     * @param string $action 方法名称
     * @param array $params 请求参数
     * @return array
     * @throws Exception
     */
    public function requestXml($method, $action, $params = [])
    {
        if (!empty($params)) {
            $params = array_filter($params, function ($a) {
                return $a !== null;
            });
        }

        $body = '';
        $query = [
            'Action' => $action,
            'Version' => $this->version,
        ];
        if ($method == 'GET' || $method == 'DELETE') {
            $query = array_merge($query, $params);
        } else {
            $body = !empty($params) ? http_build_query($params) : '';
        }

        $time = time();
        $date = gmdate("Ymd\THis\Z", $time);
        $headers = [
            'Host' => $this->endpoint,
            'X-Amz-Date' => $date,
        ];

        $path = '/';
        $authorization = $this->generateSign($method, $path, $query, $headers, $body, $date);
        $headers['Authorization'] = $authorization;

        $url = 'https://' . $this->endpoint . $path;
        if (!empty($query)) {
            $url .= '?' . http_build_query($query);
        }
        $header = [];
        foreach ($headers as $key => $value) {
            $header[] = $key . ': ' . $value;
        }
        return $this->curl($method, $url, $body, $header, true);
    }

    /**
     * @param string $method 请求方法
     * @param string $path 请求路径
     * @param array $params 请求参数
     * @param \SimpleXMLElement $xml 请求XML
     * @return array
     * @throws Exception
     */
    public function requestXmlN($method, $path, $params = [], $xml = null, $etag = false)
    {
        if (!empty($params)) {
            $params = array_filter($params, function ($a) {
                return $a !== null;
            });
        }

        $path = '/' . $this->version . $path;
        $body = '';
        $query = [];
        if ($method == 'GET' || $method == 'DELETE') {
            $query = $params;
        } else {
            $body = !empty($params) ? $this->array2xml($params, $xml) : '';
        }

        $time = time();
        $date = gmdate("Ymd\THis\Z", $time);
        $headers = [
            'Host' => $this->endpoint,
            'X-Amz-Date' => $date,
            //'X-Amz-Content-Sha256' => hash("sha256", $body),
        ];
        if ($this->etag) {
            $headers['If-Match'] = $this->etag;
        }

        $authorization = $this->generateSign($method, $path, $query, $headers, $body, $date);
        $headers['Authorization'] = $authorization;

        $url = 'https://' . $this->endpoint . $path;
        if (!empty($query)) {
            $url .= '?' . http_build_query($query);
        }
        $header = [];
        foreach ($headers as $key => $value) {
            $header[] = $key . ': ' . $value;
        }
        return $this->curl($method, $url, $body, $header, true, $etag);
    }

    private function generateSign($method, $path, $query, $headers, $body, $date)
    {
        $algorithm = "AWS4-HMAC-SHA256";

        // step 1: build canonical request string
        $httpRequestMethod = $method;
        $canonicalUri = $this->getCanonicalURI($path);
        $canonicalQueryString = $this->getCanonicalQueryString($query);
        [$canonicalHeaders, $signedHeaders] = $this->getCanonicalHeaders($headers);
        $hashedRequestPayload = hash("sha256", $body);
        $canonicalRequest = $httpRequestMethod . "\n"
            . $canonicalUri . "\n"
            . $canonicalQueryString . "\n"
            . $canonicalHeaders . "\n"
            . $signedHeaders . "\n"
            . $hashedRequestPayload;

        // step 2: build string to sign
        $shortDate = substr($date, 0, 8);
        $credentialScope = $shortDate . '/' . $this->region . '/' . $this->service . '/aws4_request';
        $hashedCanonicalRequest = hash("sha256", $canonicalRequest);
        $stringToSign = $algorithm . "\n"
            . $date . "\n"
            . $credentialScope . "\n"
            . $hashedCanonicalRequest;

        // step 3: sign string
        $kDate = hash_hmac("sha256", $shortDate, 'AWS4' . $this->SecretAccessKey, true);
        $kRegion = hash_hmac("sha256", $this->region, $kDate, true);
        $kService = hash_hmac("sha256", $this->service, $kRegion, true);
        $kSigning = hash_hmac("sha256", "aws4_request", $kService, true);
        $signature = hash_hmac("sha256", $stringToSign, $kSigning);

        // step 4: build authorization
        $credential = $this->AccessKeyId . '/' . $credentialScope;
        $authorization = $algorithm . ' Credential=' . $credential . ", SignedHeaders=" . $signedHeaders . ", Signature=" . $signature;

        return $authorization;
    }

    private function escape($str)
    {
        $search = ['+', '*', '%7E'];
        $replace = ['%20', '%2A', '~'];
        return str_replace($search, $replace, urlencode($str));
    }
    
    private function getCanonicalURI($path)
    {
        if (empty($path)) return '/';
        $pattens = explode('/', $path);
        $pattens = array_map(function ($item) {
            return $this->escape($item);
        }, $pattens);
        $canonicalURI = implode('/', $pattens);
        return $canonicalURI;
    }

    private function getCanonicalQueryString($parameters)
    {
        if (empty($parameters)) return '';
        ksort($parameters);
        $canonicalQueryString = '';
        foreach ($parameters as $key => $value) {
            $canonicalQueryString .= '&' . $this->escape($key) . '=' . $this->escape($value);
        }
        return substr($canonicalQueryString, 1);
    }

    private function getCanonicalHeaders($oldheaders)
    {
        $headers = array();
        foreach ($oldheaders as $key => $value) {
            $headers[strtolower($key)] = trim($value);
        }
        ksort($headers);

        $canonicalHeaders = '';
        $signedHeaders = '';
        foreach ($headers as $key => $value) {
            $canonicalHeaders .= $key . ':' . $value . "\n";
            $signedHeaders .= $key . ';';
        }
        $signedHeaders = substr($signedHeaders, 0, -1);
        return [$canonicalHeaders, $signedHeaders];
    }

    private function curl($method, $url, $body, $header, $xml = false, $etag = false)
    {
        $ch = curl_init($url);
        if ($this->proxy) {
            curl_set_proxy($ch);
        }
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $header);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_TIMEOUT, 10);
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
        if (!empty($body)) {
            curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
        }
        if ($etag) {
            curl_setopt($ch, CURLOPT_HEADER, true);
        }
        $response = curl_exec($ch);
        $errno = curl_errno($ch);
        if ($errno) {
            $errmsg = curl_error($ch);
            curl_close($ch);
            throw new Exception('Curl error: ' . $errmsg);
        }
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        if ($etag) {
            if (preg_match('/ETag: ([^\r\n]+)/', $response, $matches)) {
                $this->etag = trim($matches[1]);
            }
            $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
            $response = substr($response, $headerSize);
        }
        curl_close($ch);

        if ($httpCode >= 200 && $httpCode < 300) {
            if (empty($response)) return true;
            return $xml ? $this->xml2array($response) : json_decode($response, true);
        }
        if ($xml) {
            $arr = $this->xml2array($response);
            if (isset($arr['Error']['Message'])) {
                throw new Exception($arr['Error']['Message']);
            } else {
                throw new Exception('HTTP Code: ' . $httpCode);
            }
        } else {
            $arr = json_decode($response, true);
            if (isset($arr['message'])) {
                throw new Exception($arr['message']);
            } else {
                throw new Exception('HTTP Code: ' . $httpCode);
            }
        }
    }

    private function xml2array($xml)
    {
        if (!$xml) {
            return false;
        }
        LIBXML_VERSION < 20900 && libxml_disable_entity_loader(true);
        return json_decode(json_encode(simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA), JSON_UNESCAPED_UNICODE), true);
    }

    private function array2xml($array, $xml = null, $parentTagName = 'root')
    {
        if ($xml === null) {
            $xml = new \SimpleXMLElement('<root/>');
        }

        foreach ($array as $key => $value) {
            // 确定当前标签名：如果是数字键名，使用父级标签名，否则使用当前键名
            $tagName = is_numeric($key) ? $parentTagName : $key;

            if (is_array($value)) {
                // 检查数组的第一个子节点的键是否为0
                $firstKey = array_key_first($value);
                $isFirstKeyZero = ($firstKey === 0 || $firstKey === '0');

                if ($isFirstKeyZero) {
                    // 如果第一个子节点的键是0，则不生成当前节点标签，直接递归子节点
                    $this->array2xml($value, $xml, $tagName);

                } else {
                    // 否则生成当前节点标签，并递归子节点
                    $subNode = $xml->addChild($tagName);
                    $this->array2xml($value, $subNode, $tagName);
                }

            } else {
                $xml->addChild($key, $value);
            }
        }

        return $xml->asXML();
    }
}
