<?php
/**
 * WebSMS Connexus API client for OTP / 2FA flows.
 *
 * Handles:
 *   - OAuth-style client credentials -> bearer token exchange
 *   - In-memory + on-disk token cache with auto-refresh before expiry
 *   - Sending an OTP (auto-generated 6-digit code OR your own 4-8 digit code)
 *   - Server-side OTP verification with TTL + attempt tracking
 *
 * Requires PHP 7.4+ and ext-curl. No third-party dependencies.
 *
 * Usage:
 *   $client = new WebSMSClient(getenv('WEBSMS_CLIENT_ID'), getenv('WEBSMS_CLIENT_SECRET'));
 *   $r = $client->sendOTP('6421234567', 'MyApp');
 *   // ... user enters code ...
 *   if ($client->verifyOTP('6421234567', $userInput)) { ... }
 */
class WebSMSClient
{
    private const BASE_URL = 'https://websms.co.nz/api/connexus';

    /** Refresh the token this many seconds before it actually expires. */
    private const REFRESH_SKEW = 300;

    private string $clientId;
    private string $clientSecret;
    private string $tokenCachePath;
    private string $otpStorePath;

    public function __construct(string $clientId, string $clientSecret, ?string $cacheDir = null)
    {
        $this->clientId = $clientId;
        $this->clientSecret = $clientSecret;
        $cacheDir = $cacheDir ?? sys_get_temp_dir();
        $this->tokenCachePath = $cacheDir . '/websms_token_' . md5($clientId) . '.json';
        $this->otpStorePath   = $cacheDir . '/websms_otp_'   . md5($clientId) . '.json';
    }

    /**
     * Get a valid access token, refreshing transparently if missing/expired/near-expiry.
     */
    public function getAccessToken(): string
    {
        $cached = $this->loadTokenCache();
        if ($cached && $cached['expires_at'] > time() + self::REFRESH_SKEW) {
            return $cached['access_token'];
        }
        return $this->refreshToken();
    }

    /**
     * Force a fresh token regardless of cache state.
     */
    public function refreshToken(): string
    {
        $resp = $this->httpPost('/auth/token', [
            'client_id'     => $this->clientId,
            'client_secret' => $this->clientSecret,
        ], false);

        if (empty($resp['access_token'])) {
            throw new RuntimeException('Token endpoint did not return access_token: ' . json_encode($resp));
        }

        $this->saveTokenCache([
            'access_token' => $resp['access_token'],
            'expires_at'   => time() + (int) ($resp['expires_in'] ?? 86400),
        ]);

        return $resp['access_token'];
    }

    /**
     * Send an OTP. If $code is null, WebSMS generates one and returns it; either way
     * the issued code is stored locally for verifyOTP() to compare against.
     *
     * @param string      $to            E.164 mobile number without the leading '+'.
     * @param string      $company       Brand shown in message body.
     * @param string|null $code          Optional 4-8 digit code you supply yourself.
     * @param string|null $comment       Optional trailing text (e.g. "Valid for 5 minutes.").
     * @param int         $ttlSeconds    How long the code is valid for verification.
     * @param string|null $webOtpDomain  Optional bare hostname (e.g. "app.example.com").
     *                                   When set, WebSMS appends the WebOTP autofill marker
     *                                   ("@host #code") as the SMS's last line so Chrome/Safari
     *                                   can offer one-tap autofill on your origin.
     */
    public function sendOTP(
        string $to,
        string $company,
        ?string $code = null,
        ?string $comment = null,
        int $ttlSeconds = 300,
        ?string $webOtpDomain = null
    ): array
    {
        $params = [
            'to'         => $to,
            'msgCompany' => $company,
        ];
        if ($code !== null)         $params['msgCode']      = $code;
        if ($comment !== null)      $params['msgComment']   = $comment;
        if ($webOtpDomain !== null) $params['webOtpDomain'] = $webOtpDomain;

        $resp = $this->httpPost('/sms/otp', $params, true);

        if (($resp['status'] ?? null) !== 'success' || empty($resp['code'])) {
            throw new RuntimeException('OTP send failed: ' . json_encode($resp));
        }

        $this->saveOTPRecord($to, $resp['code'], $ttlSeconds);
        return $resp;
    }

    /**
     * Verify a code submitted by the user. Returns true once and only once per code:
     * on success the stored record is consumed so the same code can't be reused.
     */
    public function verifyOTP(string $to, string $userInput): bool
    {
        $store = $this->loadOTPStore();
        $rec = $store[$to] ?? null;
        if (!$rec) return false;
        if ($rec['expires_at'] < time())  { unset($store[$to]); $this->saveOTPStore($store); return false; }
        if ($rec['attempts'] >= 5)        { unset($store[$to]); $this->saveOTPStore($store); return false; }

        $store[$to]['attempts']++;
        if (hash_equals((string) $rec['code'], (string) $userInput)) {
            unset($store[$to]);
            $this->saveOTPStore($store);
            return true;
        }
        $this->saveOTPStore($store);
        return false;
    }

    // --- HTTP -----------------------------------------------------------------

    private function httpPost(string $path, array $params, bool $authenticated): array
    {
        $headers = ['Accept: application/json'];
        if ($authenticated) {
            $headers[] = 'Authorization: Bearer ' . $this->getAccessToken();
        }

        $ch = curl_init(self::BASE_URL . $path);
        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => http_build_query($params),
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 10,
            CURLOPT_CONNECTTIMEOUT => 5,
        ]);
        $body = curl_exec($ch);
        $http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $err  = curl_error($ch);
        curl_close($ch);

        if ($body === false) throw new RuntimeException("HTTP {$path} failed: {$err}");
        $data = json_decode($body, true);
        if (!is_array($data)) throw new RuntimeException("HTTP {$path} returned non-JSON ({$http}): {$body}");

        // 401 from /sms/* with a cached token means the token was revoked early - retry once.
        if ($http === 401 && $authenticated) {
            $this->refreshToken();
            return $this->httpPost($path, $params, true);
        }
        return $data;
    }

    // --- Storage helpers (atomic JSON files) ----------------------------------
    // In production you would back these with Redis/DB so they're shared across hosts.

    private function loadTokenCache(): ?array
    {
        if (!is_file($this->tokenCachePath)) return null;
        $j = json_decode((string) file_get_contents($this->tokenCachePath), true);
        return is_array($j) ? $j : null;
    }
    private function saveTokenCache(array $data): void { $this->writeJsonAtomic($this->tokenCachePath, $data); }

    private function loadOTPStore(): array
    {
        if (!is_file($this->otpStorePath)) return [];
        $j = json_decode((string) file_get_contents($this->otpStorePath), true);
        return is_array($j) ? $j : [];
    }
    private function saveOTPStore(array $store): void { $this->writeJsonAtomic($this->otpStorePath, $store); }
    private function saveOTPRecord(string $to, string $code, int $ttl): void
    {
        $store = $this->loadOTPStore();
        $store[$to] = ['code' => $code, 'expires_at' => time() + $ttl, 'attempts' => 0];
        $this->saveOTPStore($store);
    }

    private function writeJsonAtomic(string $path, array $data): void
    {
        $tmp = $path . '.' . bin2hex(random_bytes(4));
        file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT));
        chmod($tmp, 0600);
        rename($tmp, $path);
    }
}
