<?php
/**
 * TConnect Payment SDK (PHP) — single-file, no Composer deps.
 *
 * TConnect payment gateway SDK. Uses only openssl_* + curl (PHP 7.4+/8.x).
 * AES-256-CBC: a random 16-byte IV is prepended to the ciphertext, then hex-encoded.
 *
 * Docs: https://docs.tconnect.vn
 *
 * Usage example:
 *   require 'tconnect.php';
 *   $tc = new TConnect([
 *       'partnerCode'  => 'PARTNER',   'secretKey' => 'HEX_64_CHARS',
 *       'username'     => 'user',       'password'  => 'pass',
 *       'clientId'     => 'cid',        'clientSecret' => 'csecret',
 *       'serviceCode'  => 'SVC', 'va' => 'VA', 'bincode' => 'BIN',
 *   ]);
 *   $qr = $tc->createQr('ORDER-001', 100000);   // create a dynamic QR
 */

/** UUIDv4 helper (based on random_bytes). */
function tconnect_uuid4(): string
{
    $data = random_bytes(16);
    $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); // version 4
    $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); // variant 10
    return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}

/** TConnect SDK exception. */
class TConnectException extends \Exception
{
}

class TConnect
{
    /** @var string */
    private $partnerCode;
    /** @var string AES-256 key in binary form (hex2bin) */
    private $key;
    /** @var string */
    private $username;
    /** @var string */
    private $password;
    /** @var string */
    private $clientId;
    /** @var string */
    private $clientSecret;
    /** @var string|null */
    private $serviceCode;
    /** @var string|null */
    private $va;
    /** @var string|null */
    private $bincode;
    /** @var string */
    private $baseUrl;
    /** @var int */
    private $timeout;

    /** @var string|null */
    private $accessToken = null;
    /** @var string|null */
    private $refreshToken = null;
    /** @var int */
    private $accessExpireAt = 0;
    /** @var int */
    private $refreshExpireAt = 0;

    /**
     * @param array $config Config keys: partnerCode, secretKey, username, password,
     *                      clientId, clientSecret, serviceCode, va, bincode,
     *                      baseUrl, timeout.
     */
    public function __construct(array $config)
    {
        $this->partnerCode  = $config['partnerCode']  ?? '';
        $this->key          = hex2bin($config['secretKey'] ?? '');
        $this->username     = $config['username']      ?? '';
        $this->password     = $config['password']      ?? '';
        $this->clientId     = $config['clientId']      ?? '';
        $this->clientSecret = $config['clientSecret']  ?? '';
        $this->serviceCode  = $config['serviceCode']   ?? null;
        $this->va           = $config['va']            ?? null;
        $this->bincode      = $config['bincode']       ?? null;
        $this->baseUrl      = rtrim($config['baseUrl'] ?? 'https://sme-open-api-sandbox.tconnect.vn', '/');
        $this->timeout      = (int)($config['timeout'] ?? 30);
    }

    // ------------------------------------------------------------------
    // Encrypt / Decrypt (AES-256-CBC, IV prepend + hex)
    // ------------------------------------------------------------------

    /**
     * Encrypt data. Arrays/objects are json_encoded (unicode preserved, compact).
     * @param mixed $plain
     */
    public function encrypt($plain): string
    {
        if (is_array($plain) || is_object($plain)) {
            $plain = json_encode($plain, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        }
        $iv = random_bytes(16);
        // openssl adds PKCS7 padding by default.
        $ciphertext = openssl_encrypt($plain, 'aes-256-cbc', $this->key, OPENSSL_RAW_DATA, $iv);
        return bin2hex($iv . $ciphertext);
    }

    /** Decrypt a hex string (16-byte IV first + ciphertext). */
    public function decrypt(string $hex): string
    {
        $raw = hex2bin($hex);
        $iv  = substr($raw, 0, 16);
        $ct  = substr($raw, 16);
        return openssl_decrypt($ct, 'aes-256-cbc', $this->key, OPENSSL_RAW_DATA, $iv);
    }

    // ------------------------------------------------------------------
    // Authentication
    // ------------------------------------------------------------------

    /** Log in for a new token. Encrypted body, no auth required. */
    public function login(): void
    {
        $res = $this->post('/openapi/v1/auth/login', [
            'username'      => $this->username,
            'password'      => $this->password,
            'client_id'     => $this->clientId,
            'client_secret' => $this->clientSecret,
        ], false, true);
        $this->storeToken($res);
    }

    /** Refresh the token. If there is no refresh token or it has expired -> log in again. */
    public function refresh(): void
    {
        if (!$this->refreshToken || time() >= $this->refreshExpireAt) {
            $this->login();
            return;
        }
        $res = $this->post('/openapi/v1/auth/refresh', [
            'refresh_token' => $this->refreshToken,
        ], false, true);
        $this->storeToken($res);
    }

    /** Store the token from a response. */
    private function storeToken(array $res): void
    {
        $data = $res['data'] ?? $res;
        $this->accessToken  = $data['access_token']  ?? ($this->accessToken);
        $this->refreshToken = $data['refresh_token'] ?? ($this->refreshToken);
        $expiresIn        = (int)($data['expires_in']         ?? 0);
        $refreshExpiresIn = (int)($data['refresh_expires_in'] ?? 0);
        $this->accessExpireAt  = time() + max($expiresIn - 60, 0);
        $this->refreshExpireAt = time() + max($refreshExpiresIn - 60, 0);
    }

    /** Return a still-valid access token (auto refresh/login when needed). */
    public function token(): string
    {
        if (!$this->accessToken || time() >= $this->accessExpireAt) {
            if ($this->refreshToken && time() < $this->refreshExpireAt) {
                $this->refresh();
            } else {
                $this->login();
            }
        }
        return $this->accessToken;
    }

    // ------------------------------------------------------------------
    // Services
    // ------------------------------------------------------------------

    /**
     * List services. GET, NOT encrypted, with Bearer + Partner-Code.
     */
    public function getServices(
        ?string $serviceType = null,
        ?string $code = null,
        ?string $paymentMethod = null,
        int $limit = 10,
        int $page = 1
    ): array {
        $query = [
            'limit' => $limit,
            'page'  => $page,
        ];
        if ($serviceType !== null)   { $query['service_type'] = $serviceType; }
        if ($code !== null)          { $query['code'] = $code; }
        if ($paymentMethod !== null) { $query['payment_method'] = $paymentMethod; }

        $url = $this->baseUrl . '/openapi/v1/services?' . http_build_query($query);

        $headers = [
            'Partner-Code: ' . $this->partnerCode,
            'Authorization: Bearer ' . $this->token(),
        ];

        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        $response = curl_exec($ch);
        $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        return $this->parse($response, $httpCode);
    }

    // ------------------------------------------------------------------
    // Virtual Account (VA)
    // ------------------------------------------------------------------

    /** Create a VA. Encrypted body, auth required. */
    public function createVa(
        string $fullName,
        string $bankAccountNo,
        string $internalCode,
        ?string $requestId = null
    ): array {
        $requestId = $requestId ?? tconnect_uuid4();
        return $this->post('/openapi/v1/va/va-account/create', [
            'request_id'      => $requestId,
            'full_name'       => $fullName,
            'bank_account_no' => $bankAccountNo,
            'internal_code'   => $internalCode,
        ], true, true);
    }

    // ------------------------------------------------------------------
    // QR
    // ------------------------------------------------------------------

    /** Create a dynamic QR. Encrypted body, auth required, adds x-service-code header. */
    public function createQr(
        string $orderId,
        $amount = 0,
        ?string $va = null,
        ?string $bincode = null,
        ?string $serviceCode = null,
        ?string $reqId = null
    ): array {
        $va          = $va          ?? $this->va;
        $bincode     = $bincode     ?? $this->bincode;
        $serviceCode = $serviceCode ?? $this->serviceCode;
        $reqId       = $reqId       ?? tconnect_uuid4();

        if (!$va || !$bincode || !$serviceCode) {
            throw new TConnectException('createQr requires va, bincode and serviceCode');
        }

        return $this->post('/openapi/v1/transaction/qr/generate', [
            'req_id'   => $reqId,
            'order_id' => $orderId,
            'va'       => $va,
            'bincode'  => $bincode,
            'amount'   => $amount,
        ], true, true, [
            'x-service-code: ' . $serviceCode,
        ]);
    }

    // ------------------------------------------------------------------
    // Devices (POS card / Soundbox QR)
    // ------------------------------------------------------------------

    /**
     * Push a payment command to a physical device. Encrypted body, auth required.
     *
     * type='pos'      POS terminal — CARD payment. Pick the operation via $action:
     *                 SALE (default) / MOTO / PREAUTH / PREAUTH_COMPLETE / VOID / SETTLEMENT.
     *                 VOID and PREAUTH_COMPLETE require $actionData (retrievalRefNo); also send
     *                 $traceNo (and $txnId for PREAUTH_COMPLETE). SETTLEMENT needs no orderId/amount.
     * type='soundbox' Soundbox speaker — requires $qrString (from createQr).
     * $customerCode   -> X-Customer-Code header (required when the token is a Service Provider token).
     *
     * @param mixed $amount
     * @param array $extraData Extra data passed through to the device (POS only)
     */
    public function pushToDevice(
        string $serialNo,
        $amount = null,
        string $type = 'pos',
        ?string $orderId = null,
        ?string $qrString = null,
        ?string $action = null,
        ?string $actionData = null,
        ?string $traceNo = null,
        ?string $txnId = null,
        ?string $requestId = null,
        ?array $extraData = null,
        ?string $customerCode = null
    ): array {
        $body = [
            'serial_no' => $serialNo,
            'type'      => $type,
        ];
        if ($amount !== null)   { $body['amount'] = $amount; }
        if ($orderId !== null)  { $body['order_id'] = $orderId; }

        if ($type === 'soundbox') {
            if ($qrString !== null) { $body['qr_string'] = $qrString; }
        } else { // pos
            if ($action !== null)     { $body['action'] = $action; }
            if ($actionData !== null) { $body['action_data'] = $actionData; }
            if ($traceNo !== null)    { $body['trace_no'] = $traceNo; }
            if ($txnId !== null)      { $body['txn_id'] = $txnId; }
            $body['request_id'] = $requestId ?? tconnect_uuid4();
            if ($extraData !== null)  { $body['extra_data'] = $extraData; }
        }

        $extraHeaders = [];
        if ($customerCode !== null) {
            $extraHeaders[] = 'X-Customer-Code: ' . $customerCode;
        }

        return $this->post('/openapi/v1/devices/payments/push', $body, true, true, $extraHeaders);
    }

    // ------------------------------------------------------------------
    // Ecom (online payment link via Digistore)
    // ------------------------------------------------------------------

    /**
     * Create an ECOM payment link for an order. Encrypted body, auth required.
     * Returns an array with payment_id, payment_url, status, expires_at, ...
     *
     * @param mixed $amount
     * @param array|null $customer ['name' => ..., 'email' => ..., 'phone' => ...]
     */
    public function createPaymentLink(
        string $orderId,
        $amount,
        string $currency = 'VND',
        ?string $description = null,
        ?string $returnUrl = null,
        ?string $ipnUrl = null,
        int $expiresIn = 900,
        ?array $customer = null
    ): array {
        $body = [
            'order_id'   => $orderId,
            'amount'     => $amount,
            'currency'   => $currency,
            'expires_in' => $expiresIn,
        ];
        if ($description !== null) { $body['description'] = $description; }
        if ($returnUrl !== null)   { $body['return_url'] = $returnUrl; }
        if ($ipnUrl !== null)      { $body['ipn_url'] = $ipnUrl; }
        if ($customer !== null)    { $body['customer'] = $customer; }

        return $this->post('/openapi/v1/ecom/payments/link', $body, true, true);
    }

    /** Cancel a previously created ECOM payment link by order_id (the link becomes disabled). */
    public function cancelPaymentLink(string $orderId, ?string $description = null): array
    {
        $body = ['order_id' => $orderId];
        if ($description !== null) { $body['description'] = $description; }

        return $this->post('/openapi/v1/ecom/payments/cancel', $body, true, true, ['x-service-code: ecom']);
    }

    // ------------------------------------------------------------------
    // Transaction queries
    // ------------------------------------------------------------------

    /** List QR transactions. Encrypted body, auth required. from_date & to_date go together. */
    public function getQrTransactions(
        int $limit = 10,
        int $page = 1,
        ?string $fromDate = null,
        ?string $toDate = null,
        ?string $orderId = null,
        ?string $accNo = null
    ): array {
        $body = [
            'limit' => $limit,
            'page'  => $page,
        ];
        if ($fromDate !== null && $toDate !== null) {
            $body['from_date'] = $fromDate;
            $body['to_date']   = $toDate;
        }
        if ($orderId !== null) { $body['order_id'] = $orderId; }
        if ($accNo !== null)   { $body['acc_no'] = $accNo; }

        return $this->post('/openapi/v1/transaction/qr', $body, true, true);
    }

    /** Check a QR order status. Encrypted body, auth required. */
    public function checkQrOrder(string $orderId, ?string $accNo = null): array
    {
        $body = ['order_id' => $orderId];
        if ($accNo !== null) { $body['acc_no'] = $accNo; }

        return $this->post('/openapi/v1/transaction/qr/order/status', $body, true, true);
    }

    /**
     * List SUCCESSFUL CARD transactions (POS). Filter by POS serial and/or a time range.
     * Can confirm a card payment: if the order_id appears in the result, it was paid.
     * Encrypted body, auth required.
     */
    public function getCardTransactions(
        int $limit = 10,
        int $page = 1,
        ?string $fromDate = null,
        ?string $toDate = null,
        ?string $serialNo = null
    ): array {
        $body = [
            'limit' => $limit,
            'page'  => $page,
        ];
        if ($fromDate !== null && $toDate !== null) {
            $body['from_date'] = $fromDate;
            $body['to_date']   = $toDate;
        }
        if ($serialNo !== null) { $body['serial_no'] = $serialNo; }

        return $this->post('/openapi/v1/transaction/card', $body, true, true);
    }

    /** List cash transactions. Encrypted body, auth required. */
    public function getCashTransactions(
        int $limit = 10,
        int $page = 1,
        ?string $fromDate = null,
        ?string $toDate = null
    ): array {
        $body = [
            'limit' => $limit,
            'page'  => $page,
        ];
        if ($fromDate !== null && $toDate !== null) {
            $body['from_date'] = $fromDate;
            $body['to_date']   = $toDate;
        }
        return $this->post('/openapi/v1/transaction/cash', $body, true, true);
    }

    // ------------------------------------------------------------------
    // IPN
    // ------------------------------------------------------------------

    /**
     * Parse an IPN (Instant Payment Notification).
     * $body may be a JSON string or an array containing a 'data' key (encrypted hex).
     * @param string|array $body
     */
    public function parseIpn($body): array
    {
        if (is_string($body)) {
            $body = json_decode($body, true);
        }
        $hex = $body['data'] ?? null;
        $plain = $this->decrypt($hex);
        return json_decode($plain, true);
    }

    // ------------------------------------------------------------------
    // HTTP core
    // ------------------------------------------------------------------

    /**
     * Send a POST request.
     * @param mixed $payload
     */
    public function post(
        string $path,
        $payload,
        bool $auth = true,
        bool $encrypted = true,
        array $extraHeaders = []
    ): array {
        $url = $this->baseUrl . $path;

        $headers = [
            'Partner-Code: ' . $this->partnerCode,
            'Content-Type: application/json',
        ];
        if ($auth) {
            $headers[] = 'Authorization: Bearer ' . $this->token();
        }
        foreach ($extraHeaders as $h) {
            $headers[] = $h;
        }

        if ($encrypted) {
            $bodyStr = json_encode(['data' => $this->encrypt($payload)], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        } else {
            $bodyStr = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        }

        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyStr);
        $response = curl_exec($ch);
        $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        return $this->parse($response, $httpCode);
    }

    /** Parse a response. code >= 400 -> throw an exception. */
    private function parse($response, int $httpCode): array
    {
        if ($httpCode >= 400) {
            throw new TConnectException('HTTP ' . $httpCode . ': ' . $response, $httpCode);
        }
        return json_decode($response, true);
    }
}
