<?php
// =============================================================
//  services/FirebaseServiceProvider.php
//
//  Firebase Cloud Messaging via the HTTP v1 API (the legacy server-key
//  API this might otherwise use is deprecated) — sibling to
//  services/VelstackServiceProvider.php, same "one class, one send
//  method, curl directly" shape, no Composer dependency (hand-rolled
//  RS256 JWT signing via openssl_sign(), same "no external package for
//  something this self-contained" convention as include/Totp.php).
//
//  Configuration (once you've created a Firebase project — see the
//  setup steps handed to you separately):
//    1. Download the service-account JSON (Firebase Console → Project
//       settings → Service accounts → Generate new private key).
//    2. Save it somewhere outside the docroot's public reach (e.g.
//       storage/firebase-service-account.json — storage/ is already
//       gitignored) and set FIREBASE_SERVICE_ACCOUNT_PATH in .env to
//       that path.
//    3. Flip the 'firebase_enabled' row in `settings` to 1 (via
//       app/system_settings.php or include/settings_helper.php's
//       setSetting()).
//
//  Until both of those are true, isConfigured() is false and every
//  caller (include/notification_queue.php's enqueuePush(),
//  scripts/process_notification_queue.php) treats that as a normal,
//  expected state — not an error — so nothing breaks before Firebase is
//  set up. SMS (already configured) carries notifications alone until then.
// =============================================================

require_once __DIR__ . '/../include/env_loader.php';

class FirebaseServiceProvider
{
    private ?array $serviceAccount = null;

    private static ?string $cachedAccessToken       = null;
    private static int     $cachedAccessTokenExpiry  = 0;

    public function __construct()
    {
        $path = env('FIREBASE_SERVICE_ACCOUNT_PATH', '');
        if ($path === '') return;

        $isAbsolute = str_starts_with($path, '/') || (bool)preg_match('#^[A-Za-z]:[\\\\/]#', $path);
        $full = $isAbsolute ? $path : __DIR__ . '/../' . $path;
        if (!is_file($full)) return;

        $json = json_decode((string)file_get_contents($full), true);
        if (is_array($json) && isset($json['private_key'], $json['client_email'], $json['project_id'])) {
            $this->serviceAccount = $json;
        }
    }

    /** True once a valid service-account JSON has been loaded. Does NOT check the firebase_enabled setting — callers gate on that separately. */
    public function isConfigured(): bool
    {
        return $this->serviceAccount !== null;
    }

    /**
     * Sends a push notification to a single FCM device token.
     * @param array<string,scalar> $data Extra key/value payload the app can read (e.g. ['type' => 'rotation_assigned', 'schedule_id' => 42]).
     * @return array{success: bool, status_code: int, response: string}
     */
    public function sendPush(string $fcmToken, string $title, string $body, array $data = []): array
    {
        if (!$this->isConfigured()) {
            return ['success' => false, 'status_code' => 0, 'response' => 'Firebase not configured'];
        }

        $accessToken = $this->getAccessToken();
        if (!$accessToken) {
            return ['success' => false, 'status_code' => 0, 'response' => 'Could not obtain an access token'];
        }

        $stringData = [];
        foreach ($data as $k => $v) $stringData[(string)$k] = (string)$v; // FCM data payload values must all be strings

        $payload = [
            'message' => [
                'token'        => $fcmToken,
                'notification' => ['title' => $title, 'body' => $body],
                'data'         => $stringData,
            ],
        ];

        $projectId = $this->serviceAccount['project_id'];
        $ch = curl_init("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send");
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
        curl_setopt($ch, CURLOPT_HTTPHEADER, [
            'Authorization: Bearer ' . $accessToken,
            'Content-Type: application/json',
        ]);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_TIMEOUT, 10);
        $result   = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        return ['success' => $httpCode === 200, 'status_code' => $httpCode, 'response' => (string)$result];
    }

    /**
     * Exchanges the service account's signed JWT for a short-lived OAuth2
     * access token (Google's standard server-to-server flow), cached for
     * the remainder of its lifetime so a burst of sends doesn't re-sign
     * and re-exchange for every single push.
     */
    private function getAccessToken(): ?string
    {
        if (self::$cachedAccessToken !== null && self::$cachedAccessTokenExpiry > time() + 30) {
            return self::$cachedAccessToken;
        }

        $jwt = $this->buildSignedJwt();
        if ($jwt === null) return null;

        $ch = curl_init('https://oauth2.googleapis.com/token');
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
            'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
            'assertion'  => $jwt,
        ]));
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_TIMEOUT, 10);
        $result   = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($httpCode !== 200) {
            error_log('FirebaseServiceProvider: token exchange failed, HTTP ' . $httpCode . ' — ' . $result);
            return null;
        }

        $data = json_decode((string)$result, true);
        if (!isset($data['access_token'])) return null;

        self::$cachedAccessToken      = $data['access_token'];
        self::$cachedAccessTokenExpiry = time() + (int)($data['expires_in'] ?? 3600);
        return self::$cachedAccessToken;
    }

    /** RFC 7519 JWT, RS256-signed with the service account's private key — Google's documented server-to-server auth flow. */
    private function buildSignedJwt(): ?string
    {
        $now = time();
        $segments = [
            self::base64url(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])),
            self::base64url(json_encode([
                'iss'   => $this->serviceAccount['client_email'],
                'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
                'aud'   => 'https://oauth2.googleapis.com/token',
                'iat'   => $now,
                'exp'   => $now + 3600,
            ])),
        ];

        $signature = '';
        $ok = openssl_sign(implode('.', $segments), $signature, $this->serviceAccount['private_key'], 'sha256WithRSAEncryption');
        if (!$ok) {
            error_log('FirebaseServiceProvider: failed to sign JWT — check the service account private key');
            return null;
        }
        $segments[] = self::base64url($signature);

        return implode('.', $segments);
    }

    public static function base64url(string $data): string
    {
        return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
    }
}
