<?php
declare(strict_types=1);

/** Server-side only. Keep credentials and the token cache outside the public web root. */
final class BertigrafApiClient
{
    private const BASE = 'https://backend.bertigraf.nl/api/v1';

    public function __construct(
        private string $clientId,
        private string $clientSecret,
        private string $tokenCachePath
    ) {
        if ($clientId === '' || $clientSecret === '') {
            throw new InvalidArgumentException('Bertigraf client credentials are required.');
        }
        $parent = dirname($tokenCachePath);
        if (!is_dir($parent) || !is_writable($parent)) {
            throw new InvalidArgumentException('Use a writable private directory for the token cache.');
        }
        $documentRoot = $_SERVER['DOCUMENT_ROOT'] ?? '';
        $public = $documentRoot !== '' ? realpath($documentRoot) : false;
        $private = realpath($parent);
        if ($public !== false && $private !== false &&
            (strcasecmp($private, $public) === 0 ||
             str_starts_with(strtolower($private . DIRECTORY_SEPARATOR), strtolower($public . DIRECTORY_SEPARATOR)))) {
            throw new InvalidArgumentException('Token cache must be outside the public web root.');
        }
    }

    /** Example: request('GET', '/website/inventory?productId=12345'). */
    public function request(string $method, string $path, ?array $body = null, array $headers = []): array
    {
        if (!in_array($method, ['GET', 'POST'], true) || !str_starts_with($path, '/')) {
            throw new InvalidArgumentException('Use GET/POST and a relative API path starting with /.');
        }
        $token = $this->accessToken();
        [$status, $response] = $this->send($method, $path, $body, $headers, $token);
        if ($status === 401) {
            // One automatic recovery attempt; revoked grants never loop forever.
            $token = $this->accessToken($token);
            [$status, $response] = $this->send($method, $path, $body, $headers, $token);
        }
        return $this->successful($status, $response);
    }

    private function accessToken(?string $failedToken = null): string
    {
        $handle = fopen($this->tokenCachePath, 'c+b');
        if ($handle === false) {
            throw new RuntimeException('Cannot open private token cache.');
        }
        @chmod($this->tokenCachePath, 0600);
        try {
            if (!flock($handle, LOCK_EX)) {
                throw new RuntimeException('Cannot lock token cache.');
            }
            $cache = json_decode(stream_get_contents($handle), true);
            if (!is_array($cache)) {
                $cache = [];
            }
            $cachedToken = $cache['access_token'] ?? '';
            $sameClient = ($cache['client_id'] ?? '') === $this->clientId;
            // Other workers can reuse a token already renewed after this request failed.
            if ($sameClient && is_string($cachedToken) && $cachedToken !== '' &&
                ($cache['expires_at_epoch'] ?? 0) > time() + 120 &&
                ($failedToken === null || !hash_equals($cachedToken, $failedToken))) {
                return $cachedToken;
            }
            [$status, $response] = $this->send('POST', '/auth/token', [
                'grant_type' => 'client_credentials',
                'client_id' => $this->clientId,
                'client_secret' => $this->clientSecret,
            ], [], null);
            $response = $this->successful($status, $response);
            if (!is_string($response['access_token'] ?? null) ||
                ($response['expires_in'] ?? 0) < 1) {
                throw new RuntimeException('Invalid token endpoint response.');
            }
            $cache = json_encode([
                'client_id' => $this->clientId,
                'access_token' => $response['access_token'],
                'expires_at_epoch' => time() + (int)$response['expires_in'],
            ], JSON_THROW_ON_ERROR);
            rewind($handle);
            if (!ftruncate($handle, 0) || fwrite($handle, $cache) !== strlen($cache) || !fflush($handle)) {
                throw new RuntimeException('Cannot persist the private token cache.');
            }
            return $response['access_token'];
        } finally {
            flock($handle, LOCK_UN);
            fclose($handle);
        }
    }

    private function send(string $method, string $path, ?array $body, array $extraHeaders, ?string $token): array
    {
        $headers = ['Accept: application/json'];
        foreach ($extraHeaders as $name => $value) {
            if (!preg_match('/^[A-Za-z0-9-]+$/D', (string)$name) ||
                strpbrk((string)$value, "\r\n") !== false ||
                in_array(strtolower((string)$name), ['authorization', 'content-type', 'host'], true)) {
                throw new InvalidArgumentException('Invalid extra request header.');
            }
            $headers[] = $name . ': ' . $value;
        }
        if ($token !== null) {
            $headers[] = 'Authorization: Bearer ' . $token;
        }
        if ($body !== null) {
            $headers[] = 'Content-Type: application/json';
        }
        $curl = curl_init(self::BASE . $path);
        curl_setopt_array($curl, [
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
        ]);
        if ($body !== null) {
            curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($body, JSON_THROW_ON_ERROR));
        }
        try {
            $raw = curl_exec($curl);
            if ($raw === false) {
                throw new RuntimeException('Bertigraf API transport failed (cURL ' . curl_errno($curl) . ').');
            }
            $status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
            $decoded = json_decode($raw, true);
            if (!is_array($decoded)) {
                throw new RuntimeException('Bertigraf API returned a non-JSON response.');
            }
            return [$status, $decoded];
        } finally {
            curl_close($curl);
        }
    }

    private function successful(int $status, array $body): array
    {
        if ($status < 200 || $status >= 300) {
            // Never log request bodies, Authorization headers or client credentials.
            throw new RuntimeException('Bertigraf API HTTP ' . $status . ': ' . ($body['error'] ?? 'Request failed'));
        }
        return $body;
    }
}

/* Usage in private website backend code:
require_once '/private/BertigrafApiClient.php';
$api = new BertigrafApiClient(
    getenv('BERTIGRAF_CLIENT_ID'),
    getenv('BERTIGRAF_CLIENT_SECRET'),
    '/private/bertigraf-access-token.json'
);
$stock = $api->request('GET', '/website/inventory?productId=12345');
$receipt = $api->request('POST', '/orders', $order, ['Idempotency-Key' => $order['externalOrderId']]);
*/
