<?php
/**
 * @package     MefisAssetBridge
 * @subpackage  MefisAssetBridgeCore_Api
 *
 * @copyright   Copyright (C) 2025 Goran Usljebrka. All rights reserved.
 * @license     Proprietary — No resale or incorporation into other projects without explicit written permission. see LICENSE.txt
 */

namespace MefisAssetBridgeCore\Api;

use MefisAssetBridgeCore\Api\Response\ApiResponse;
use MefisAssetBridgeCore\Auth\AuthenticatedUser;
use MefisAssetBridgeCore\Auth\KeyStore;
use MefisAssetBridgeCore\Log;

/**
 * API Client
 */
class ApiClient
{
    /** @var string API base URL */
    private string $baseUrl;

    /** @var bool SSL check flag */
    private bool $sslCheck = true;

    private KeyStore $KeyStore;

    /** @var string Component version */
    private string $componentVersion = '0.0.0';

    /** @var AuthenticatedUser|null Authenticated user */
    private ?AuthenticatedUser $user;
    private $domain;
    private $cmsType;
    private $cmsVersion;
    private $cmsUserId;
    private $deviceInfo;
    private string $uuid = '';

    /**
     * Create a new ApiClient instance
     *
     * @param string $baseUrl API base URL
     * @param AuthenticatedUser|null $user Authenticated user
     */
    public function __construct(string $baseUrl, ?AuthenticatedUser $user = null)
    {
        $this->baseUrl = rtrim($baseUrl, '/');
        $this->user    = $user;
    }

    /**
     * Set the authenticated user
     *
     * @param AuthenticatedUser|null $user Authenticated user
     */
    public function setUser(?AuthenticatedUser $user): void
    {
        $this->user = $user;
    }
    public function setBaseUrl(string $baseUrl): void
    {
        $this->baseUrl = rtrim($baseUrl, '/');
    }

    public function setKeystore(KeyStore $KeyStore): void
    {
        $this->KeyStore = $KeyStore;
    }

    /**
     * Send GET request
     *
     * @param string $endpoint API endpoint
     * @param array $params Request parameters
     * @return ApiResponse
     */
    public function get(string $endpoint, array $params = []): ApiResponse
    {
        return $this->request('GET', $endpoint, $params);
    }

    /**
     * Send POST request
     *
     * @param string $endpoint API endpoint
     * @param array $data Request data
     * @param array $params Query parameters
     * @return ApiResponse
     */
    public function post(string $endpoint, array $data = [], array $params = [], array $files = []): ApiResponse
    {
        return $this->request('POST', $endpoint, $params, $data, $files);
    }

    /**
     * Send PUT request
     *
     * @param string $endpoint API endpoint
     * @param array $data Request data
     * @param array $params Query parameters
     * @return ApiResponse
     */
    public function put(string $endpoint, array $data = [], array $params = []): ApiResponse
    {
        return $this->request('PUT', $endpoint, $params, $data);
    }

    /**
     * Send PATCH request
     *
     * @param string $endpoint API endpoint
     * @param array $data Request data
     * @param array $params Query parameters
     * @return ApiResponse
     */
    public function patch(string $endpoint, array $data = [], array $params = [], array $files = []): ApiResponse
    {
        return $this->request('PATCH', $endpoint, $params, $data, $files);
    }

    /**
     * Send DELETE request
     *
     * @param string $endpoint API endpoint
     * @param array $params Query parameters
     * @return ApiResponse
     */
    public function delete(string $endpoint, array $params = []): ApiResponse
    {
        return $this->request('DELETE', $endpoint, $params);
    }

    /**
     * Send HTTP request
     *
     * @param string $method HTTP method
     * @param string $endpoint API endpoint
     * @param array $params Query parameters
     * @param array $data Request data
     * @param array $files Files to upload
     * @return ApiResponse
     */
    private function request(string $method, string $endpoint, array $params = [], array $data = [], array $files = [], bool $isBinary = false): ApiResponse
    {
        $log = Log::getInstance();
        // Check if cURL extension is available
        if (!extension_loaded('curl') || !function_exists('curl_init')) {
            $phpIniPath = php_ini_loaded_file();
            $errorMessage = "The PHP cURL extension is not enabled. ";
            $errorMessage .= "Please enable it in your php.ini file ({$phpIniPath}) and restart your web server.";

            $log->error("cURL extension not available. Cannot make API request.", 'api-client');

            return ApiResponse::error($errorMessage, null, 500);
        }

        $url = $this->baseUrl . '/' . ltrim($endpoint, '/');

        $customHeaders = [];

        if (! empty($params)) {

            if (! empty($params['headers'])) {
                $customHeaders = $params['headers'];
                unset($params['headers']);
            }

            $url .= '?' . http_build_query($params);
        }

        $ch = curl_init($url);

        // get user agent and trim it to 160 characters
        $userAgent = '';
        if (isset($_SERVER['HTTP_USER_AGENT'])) {
            $userAgent = substr($_SERVER['HTTP_USER_AGENT'], 0, 160);
        }
        $headers = [
            'Accept: application/json',
            'User-Agent: MAB/' . $this->componentVersion . ' (UA:' . $userAgent . ')',
        ];

        foreach ($customHeaders as $header) {
            $headers[] = $header;
        }

        // Add authorization header if user is authenticated
        if ($this->user !== null && $this->user->isAuthenticated()) {
            $headers[] = 'Authorization: Bearer ' . $this->user->getToken();
        }

        // prepare signature
        $timestamp = time();
        if (in_array($method, ['GET', 'DELETE'])) {
            $uriPath     = parse_url($url, PHP_URL_PATH);
            $queryString = parse_url($url, PHP_URL_QUERY);
            $payload     = "{$method}\n{$uriPath}\n{$queryString}\n{$timestamp}\n{$this->uuid}";

            $headers[] = 'X-MAB-Timestamp: ' . $timestamp;
            $headers[] = 'X-MAB-UUID: ' . $this->uuid;
            if ($this->KeyStore->areKeysValid() === 3) {
                $headers[] = 'X-MAB-Signature: ' . $this->KeyStore->sign($payload);
            }
        }

        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_ENCODING, "");
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

        // most localhost environments do not work with SSL verification
        if (! $this->sslCheck) {
            curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
            curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
        } else {
            curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
            curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
        }

        if (! empty($files) && in_array($method, ['POST', 'PUT', 'PATCH'])) {
            // 1. Generiši canonical JSON jednom i koristi ga i za potpis i za polje 'json'
            $canonicalJson = $this->canonicalizeJson($data);

            // 2. Potpisuj samo JSON deo, nikako fajlove
            if ($this->KeyStore->areKeysValid() === 3) {
                $headers[] = 'X-MAB-Signature: ' . $this->KeyStore->sign($canonicalJson);
                $headers[] = 'X-MAB-Timestamp: ' . $timestamp;
                $headers[] = 'X-MAB-UUID: ' . $this->uuid;
            }

            // 3. Napravi osnovni deo forme
            $postFields = [
                'json' => $canonicalJson,
            ];

            // 4. Dodaj fajlove – podrška i za jednostruki i za višestruki upload
            foreach ($files as $key => $filePaths) {
                if (! is_array($filePaths)) {
                    $filePaths = [$filePaths];
                }
                foreach ($filePaths as $index => $filePath) {
                    if (is_string($filePath) && file_exists($filePath)) {
                        // npr. files[0], files[1]...
                        $postFields[$key . '[' . $index . ']'] = new \CURLFile($filePath);
                    }
                }
            }

            // 5. Postavi telo zahteva (multipart/form-data)
            curl_setopt($ch, CURLOPT_POSTFIELDS, $postFields);

            // 6. Nemoj ručno dodavati Content-Type (cURL će sam postaviti)
            curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        } elseif (in_array($method, ['POST', 'PUT', 'PATCH'])) {
            // Regular JSON request (no files) - keep original signature in body logic
            if ($this->KeyStore->areKeysValid() === 3) {
                $data['timestamp'] = $timestamp;
                $dataV2['payload'] = $data;
                $dataV2['uuid'] = $this->uuid;
                $dataV2['signature'] = $this->KeyStore->sign($this->canonicalizeJson($dataV2['payload']));
            } else {
                $dataV2 = $data;
            }
            $headers[] = 'Content-Type: application/json';
            curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
            curl_setopt($ch, CURLOPT_POSTFIELDS, $this->canonicalizeJson($dataV2));
        } else {
            // GET, DELETE, etc.
        }

        switch ($method) {
            case 'POST':
                if (empty($files)) {
                    curl_setopt($ch, CURLOPT_POST, true);
                } // For files, POST is already set by CURLOPT_POSTFIELDS
                break;

            case 'PUT':
                curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
                break;

            case 'PATCH':
                curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
                break;

            case 'DELETE':
                curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
                break;

            case 'GET':
            default:
                // GET is the default
                break;
        }

        if(MEFIS_AB_DEBUG) {
            // DEBUG
            curl_setopt($ch, CURLINFO_HEADER_OUT, true);
            curl_setopt($ch, CURLOPT_VERBOSE, true);
        }

        $response   = curl_exec($ch);
        $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $error      = curl_error($ch);

        curl_close($ch);
        $log = Log::getInstance();

        $payload = isset($dataV2)
            ? $this->canonicalizeJson($dataV2)
            : 'N/A';

        $responseLog = is_string($response)
            ? substr($response, 0, 5000)
            : '[binary data]';

        $log->debug(
            sprintf(
                'API Request: %s %s | Status: %s',
                $method,
                $url,
                $statusCode
            ),
            'api-client'
        );

        $log->debug(
            sprintf('API Payload: %s | URL: %s', $payload, $url),
            'api-client'
        );

        $log->debug(
            sprintf('API Response: %s | URL: %s', $responseLog, $url),
            'api-client'
        );

        if ($error) {
            $log->error("API request error: " . $error . " | URL: " . $url, 'api-client');
            // check if SSL or "certificate" is mentioned
            if(preg_match_all('/(SSL|certificate)/i', $error, $matches)) {
                $settingsUrl = ($this->cmsType === 'WordPress')
                    ? 'wp-admin/admin.php?page=mefis-asset-bridge-settings'
                    : 'index.php?option=com_config&view=component&component=com_mefisassetbridge';
                return ApiResponse::error("Mefi's Asset Bridge is not able to establish a safe connection (SSL) with the remote server. This often happens when it is run locally. You can disable SSL verification in the <a href='" . $settingsUrl . "'>plugin settings</a>, but do not do that in production.", $error, 0);
            }
            return ApiResponse::error("Something went wrong, please try again later.", null, 500);
        }

        // Handle binary responses without JSON decoding
        if ($isBinary) {
            $success = $statusCode >= 200 && $statusCode < 300;
            return new ApiResponse(
                $success,
                $response, // Raw binary data
                $success ? 'Binary data retrieved' : 'Error retrieving binary data',
                $statusCode
            );
        }

        // Decode JSON response
        $responseData = json_decode($response, true);

        if (json_last_error() !== JSON_ERROR_NONE) {
            $log->error("API response JSON decode error: " . json_last_error_msg() . " | Response: " . substr($response, 0, 500), 'api-client');
            return ApiResponse::error('Something went wrong, please try again later!', $response, $statusCode);
        }

        // Ensure responseData is an array
        if (!is_array($responseData)) {
            $log->error("API response is not a valid array after JSON decode | Response: " . substr($response, 0, 500), 'api-client');
            return ApiResponse::error('Invalid response format received from server.', $response, $statusCode);
        }

        // Handle 429 rate limit errors with detailed payload
        // Payload contains: type (RATE_LIMIT_EXCEEDED | ABUSE_DETECTED | QUOTA_EXCEEDED),
        // details (retry_after, quota info, etc.), and descriptive message from server
        // Access via: $response->getData()['type'] and $response->getData()['details']
        if ($statusCode == 429) {
            $payload = $responseData['payload'] ?? [];

            // Ensure payload is an array
            if (!is_array($payload)) {
                $payload = [];
            }

            $message = $payload['message'] ?? 'Too many requests from this (server) IP address. Please try again later or contact us if you think this is a mistake.';
            return ApiResponse::error($message, $payload, $statusCode);
        }

        // Handle normal responses
        $success = $statusCode >= 200 && $statusCode < 300;
        $payload = $responseData['payload'] ?? null;
        $message = $payload['message'] ?? ($success ? 'Success' : 'Error');

        return new ApiResponse(
            $success,
            $responseData['payload'] ?? $responseData,
            $message,
            $statusCode
        );
    }

    public function setSSLCheck(bool $check): void
    {
        $this->sslCheck = $check === true;
    }
    public function setComponentVersion(string $version): void
    {
        $this->componentVersion = $version;
    }

    public function setDeviceInfo(string $str): self
    {
        $this->deviceInfo = $str;
        return $this;
    }

    public function setDomain(string $str): self
    {
        $this->domain = $str;
        return $this;
    }

    public function setCMSUserId(int $int): self
    {
        $this->cmsUserId = $int;
        return $this;
    }
    public function setCMSVersion(string $version): self
    {
        $this->cmsVersion = $version;
        return $this;
    }
    public function setCMSType(string $type): self
    {
        $this->cmsType = $type;
        return $this;
    }
    public function getDomain(): string
    {
        return $this->domain;
    }
    public function getDeviceInfo(): string
    {
        return $this->deviceInfo;
    }
    public function getCMSUserId(): int
    {
        return $this->cmsUserId;
    }
    public function getCMSVersion(): string
    {
        return $this->cmsVersion;
    }
    public function getCMSType(): string
    {
        return $this->cmsType;
    }
    public function setUuid(string $uuid): void
    {
        $this->uuid = $uuid;
    }
    public function getKeyStore(): KeyStore
    {
        return $this->KeyStore;
    }
    /**
     * Canonicalize input to deterministic JSON: deep sort associative keys then json_encode.
     */
    private function canonicalizeJson($data): string
    {
        $normalize = function (&$v) use (&$normalize): void {
            if (is_array($v)) {
                // detect associative vs sequential
                $assoc = array_keys($v) !== range(0, count($v) - 1);
                if ($assoc) {
                    ksort($v);
                    foreach ($v as &$item) {
                        $normalize($item);
                    }
                } else {
                    foreach ($v as &$item) {
                        $normalize($item);
                    }
                }
            }
        };

        $a = $data;
        $normalize($a);
        return json_encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    }

    /**
     * Send GET request for binary data
     *
     * @param string $endpoint API endpoint
     * @param array $params Request parameters
     * @return ApiResponse
     */
    public function getBinary(string $endpoint, array $params = []): ApiResponse
    {
        return $this->request('GET', $endpoint, $params, [], [], true);
    }
}
