<?php
defined('ABSPATH') || exit;

use Google\Auth\Credentials\UserRefreshCredentials;
use Google\Shopping\Merchant\DataSources\V1\Client\DataSourcesServiceClient;
use Google\Shopping\Merchant\DataSources\V1\CreateDataSourceRequest;
use Google\Shopping\Merchant\DataSources\V1\DataSource;
use Google\Shopping\Merchant\DataSources\V1\DataSource\Input;
use Google\Shopping\Merchant\DataSources\V1\ListDataSourcesRequest;
use Google\Shopping\Merchant\DataSources\V1\PrimaryProductDataSource;

/**
 * Manages Google OAuth credentials and tokens.
 *
 * Settings (client_id, client_secret, merchant_id, feed config) are stored
 * in wp_options under 'jra_merchant_settings'.
 *
 * Tokens (access_token, refresh_token, expiry, connection state) are stored
 * separately under 'jra_merchant_tokens' — never exported in settings exports.
 *
 * OAuth redirect URI: admin-post.php?action=jra_merchant_oauth_callback
 * This must be registered as an authorised redirect URI in the Google Cloud
 * project that owns the client_id / client_secret.
 */
class JRA_Merchant_Auth {

    private JRA_Merchant_Encryption $encryption;
    private Google\Client           $client;

    public function __construct() {
        $this->encryption = new JRA_Merchant_Encryption();
        $this->client     = $this->buildClient();
    }

    // -------------------------------------------------------------------------
    // Settings
    // -------------------------------------------------------------------------

    /** @return array<string,mixed> */
    public function getSettings(): array {
        return (array) get_option('jra_merchant_settings', []);
    }

    /**
     * Returns the configured markets list.
     * Falls back to the legacy single target_country/content_language for backward compat.
     *
     * @return array<array{country: string, language: string}>
     */
    public function getMarkets(): array {
        $s = $this->getSettings();

        if (!empty($s['markets']) && is_array($s['markets'])) {
            $markets = $s['markets'];
        } else {
            // Legacy single-country fallback
            $country  = strtoupper($s['target_country']  ?? 'GB');
            $language = strtolower($s['content_language'] ?? 'en');
            $markets  = [['country' => $country, 'language' => $language]];
        }

        if ( jpffgmc_fs()->is__premium_only() ) {
            return $markets;
        }

        // Free plan: return only the designated primary market (or first in list)
        $primaryCode = strtoupper($s['primary_market'] ?? '');
        if ($primaryCode !== '') {
            usort($markets, fn($a, $b) => $a['country'] === $primaryCode ? -1 : 1);
        }
        return array_slice($markets, 0, 1);
    }

    public function hasSettings(): bool {
        $s = $this->getSettings();
        return !empty($s['client_id_encrypted']) && !empty($s['client_secret_encrypted']);
    }

    /**
     * Persist OAuth credentials and/or feed settings.
     * Pass empty strings for client_id / client_secret to leave them unchanged.
     *
     * @param array<string,string> $extra  Keys: merchant_id, store_name, target_country,
     *                                     content_language, currency, default_brand
     */
    public function saveSettings(string $clientId, string $clientSecret, array $extra = []): void {
        $settings = $this->getSettings();

        if ($clientId !== '') {
            $settings['client_id_encrypted'] = $this->encryption->encrypt($clientId);
        }
        if ($clientSecret !== '') {
            $settings['client_secret_encrypted'] = $this->encryption->encrypt($clientSecret);
        }

        $allowed = ['merchant_id', 'store_name', 'currency', 'default_brand', 'default_google_category', 'low_stock_threshold', 'primary_market', 'default_lead_time', 'due_date_meta_key', 'due_date_meta_format', 'lead_time_meta_key', 'bundle_price_meta_key', 'group_title_prefix'];
        foreach ($allowed as $key) {
            if (array_key_exists($key, $extra)) {
                $settings[$key] = sanitize_text_field((string)$extra[$key]);
            }
        }

        // Markets: array of ['country' => 'GB', 'language' => 'en']
        if (array_key_exists('markets', $extra)) {
            $settings['markets'] = $extra['markets'];
        }

        update_option('jra_merchant_settings', $settings, false);

        // Rebuild client with updated credentials
        $this->client = $this->buildClient();
    }

    // -------------------------------------------------------------------------
    // OAuth flow
    // -------------------------------------------------------------------------

    /**
     * Generate the Google authorisation URL and store a CSRF state token.
     */
    public function getAuthUrl(): string {
        if (!$this->hasSettings()) {
            throw new RuntimeException('Google API credentials are not configured.');
        }

        $state = wp_generate_password(32, false);
        set_transient('jra_merchant_oauth_state', $state, 300); // 5-minute window

        $this->client->setState($state);
        $this->client->setRedirectUri($this->getRedirectUri());
        return $this->client->createAuthUrl();
    }

    /**
     * Exchange the auth code for tokens and persist them.
     * Validates the state parameter to prevent CSRF.
     */
    public function handleCallback(string $code, string $state): void {
        $stored = get_transient('jra_merchant_oauth_state');
        delete_transient('jra_merchant_oauth_state');

        if (!$stored || !hash_equals((string)$stored, $state)) {
            throw new RuntimeException('OAuth state mismatch — possible CSRF attempt. Please try connecting again.');
        }

        $this->client->setRedirectUri($this->getRedirectUri());
        $token = $this->client->fetchAccessTokenWithAuthCode($code);

        if (isset($token['error'])) {
            throw new RuntimeException('OAuth error: ' . ($token['error_description'] ?? $token['error'])); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
        }
        if (empty($token['access_token'])) {
            throw new RuntimeException('No access token returned from Google.');
        }

        $this->storeTokens($token);
    }

    // -------------------------------------------------------------------------
    // Authenticated client
    // -------------------------------------------------------------------------

    /**
     * Return an authenticated Google\Client, refreshing the access token if needed.
     *
     * @throws RuntimeException if not connected or token refresh fails.
     */
    public function getAuthenticatedClient(): Google\Client {
        $tokens = $this->getTokens();

        if (!($tokens['connected'] ?? false)) {
            throw new RuntimeException('Not connected to Google Merchant Center. Complete the OAuth flow first.');
        }

        $accessToken  = $this->encryption->decrypt($tokens['access_token_encrypted']  ?? '');
        $refreshToken = $this->encryption->decrypt($tokens['refresh_token_encrypted'] ?? '');

        // Reconstruct token array for the Google client
        $tokenExpires = $tokens['token_expires'] ?? '';
        $createdAt    = $tokenExpires !== '' ? (strtotime($tokenExpires) - 3600) : (time() - 7200);

        $this->client->setAccessToken([
            'access_token'  => $accessToken,
            'refresh_token' => $refreshToken,
            'expires_in'    => 3600,
            'created'       => $createdAt,
        ]);

        if ($this->client->isAccessTokenExpired()) {
            $newToken = $this->client->fetchAccessTokenWithRefreshToken($refreshToken);

            if (isset($newToken['error'])) {
                $this->markDisconnected($newToken['error_description'] ?? $newToken['error']);
                throw new RuntimeException(
                    'Google token refresh failed: ' . ($newToken['error_description'] ?? $newToken['error']) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
                );
            }

            // Google only returns a new refresh_token on the first authorisation;
            // preserve the existing one if not included in the refresh response.
            $this->storeTokens(array_merge(['refresh_token' => $refreshToken], $newToken));
            $this->client->setAccessToken($newToken);
        }

        return $this->client;
    }

    public function isConnected(): bool {
        return (bool)($this->getTokens()['connected'] ?? false);
    }

    // -------------------------------------------------------------------------
    // Merchant API (GAPIC) helpers
    // -------------------------------------------------------------------------

    /**
     * Build credentials for the Merchant API GAPIC clients. These manage their
     * own token refresh independently of the Google\Client instance above —
     * this is the standard pattern for GAPIC clients and requires the same
     * client_id/secret/refresh_token already stored for the Content-API-era
     * OAuth flow (the 'auth/content' scope is unchanged, so no reconnection
     * is required).
     */
    public function buildGapicCredentials(): UserRefreshCredentials {
        $tokens = $this->getTokens();
        if (!($tokens['connected'] ?? false)) {
            throw new RuntimeException('Not connected to Google Merchant Center. Complete the OAuth flow first.');
        }
        $settings = $this->getSettings();
        return new UserRefreshCredentials(['https://www.googleapis.com/auth/content'], [
            'client_id'     => $this->encryption->decrypt($settings['client_id_encrypted'] ?? ''),
            'client_secret' => $this->encryption->decrypt($settings['client_secret_encrypted'] ?? ''),
            'refresh_token' => $this->encryption->decrypt($tokens['refresh_token_encrypted'] ?? ''),
        ]);
    }

    /**
     * Common options for constructing any Merchant API GAPIC service client.
     * Forces the REST transport — shared WordPress hosting generally lacks
     * the grpc PECL extension, so we don't rely on autodetection.
     *
     * @return array{credentials: UserRefreshCredentials, transport: string}
     */
    public function gapicClientOptions(): array {
        return [
            'credentials' => $this->buildGapicCredentials(),
            'transport'   => 'rest',
        ];
    }

    /**
     * Registers this OAuth-connected Google Cloud project as a developer with
     * the Merchant Center account. Merchant API rejects every call from an
     * unregistered GCP project (error reason GCP_NOT_REGISTERED); Content-API-era
     * connections predate this requirement, so each GCP project needs to do
     * this once before Merchant API calls (including delta/full sync) succeed.
     * There is no Merchant Center UI equivalent — API only.
     *
     * @see https://developers.google.com/merchant/api/guides/quickstart/registration
     */
    public function registerGcpDeveloper(): void {
        $merchantId = $this->getMerchantId();
        if ($merchantId === '') {
            throw new RuntimeException('No Google Merchant Center merchant ID configured. Set it in Settings.');
        }

        $accessToken = $this->getAuthenticatedClient()->getAccessToken()['access_token'] ?? '';
        if ($accessToken === '') {
            throw new RuntimeException('No valid access token available.');
        }

        $response = wp_remote_post(
            "https://merchantapi.googleapis.com/accounts/v1/accounts/{$merchantId}/developerRegistration:registerGcp",
            [
                'headers' => [
                    'Authorization' => 'Bearer ' . $accessToken,
                    'Content-Type'  => 'application/json',
                ],
                'body'    => wp_json_encode(['developerEmail' => get_bloginfo('admin_email')]),
                'timeout' => 20,
            ]
        );

        if (is_wp_error($response)) {
            throw new RuntimeException('Registration request failed: ' . $response->get_error_message()); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
        }

        $code = wp_remote_retrieve_response_code($response);
        if ($code < 200 || $code >= 300) {
            throw new RuntimeException(
                "Registration failed (HTTP {$code}): " . wp_remote_retrieve_body($response) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
            );
        }
    }

    /**
     * Resolve (and cache) the primary product data source used for API-inserted
     * products. Content API used to create this implicitly on first insert;
     * Merchant API requires it to exist upfront. Existing Content-API-era
     * listings already live in a data source that's visible here, so this
     * will normally find and reuse it rather than create a new one.
     */
    public function getOrCreateDataSourceName(): string {
        $settings = $this->getSettings();
        $cached   = trim((string)($settings['data_source_name'] ?? ''));
        if ($cached !== '') {
            return $cached;
        }

        $merchantId = $this->getMerchantId();
        if ($merchantId === '') {
            throw new RuntimeException('No Google Merchant Center merchant ID configured. Set it in Settings.');
        }

        $client = new DataSourcesServiceClient($this->gapicClientOptions());
        $parent = DataSourcesServiceClient::accountName($merchantId);

        $name    = null;
        $request = new ListDataSourcesRequest([
            'parent'    => $parent,
            'page_size' => 100,
        ]);
        // iterateAllElements() transparently pages through every data source on the account.
        foreach ($client->listDataSources($request)->iterateAllElements() as $dataSource) {
            /** @var DataSource $dataSource */
            $primary = $dataSource->getPrimaryProductDataSource();
            // Must be a primary product data source, not the read-only Content-API-era
            // "legacy local" kind, and — critically — of Input::API type. A data source
            // can be primary and non-legacy yet still be FILE/UI/AUTOFEED (e.g. a
            // scheduled-fetch feed set up in Merchant Center); pushing products via the
            // API into one of those fails with "API data sources cannot have a
            // fileInput field set", so it must be excluded here too.
            if ($primary && !$primary->getLegacyLocal() && $dataSource->getInput() === Input::API) {
                $name = $dataSource->getName();
                break;
            }
        }

        if ($name === null) {
            $dataSource = new DataSource([
                'display_name'                => 'API products',
                'primary_product_data_source' => new PrimaryProductDataSource(),
            ]);
            $request = new CreateDataSourceRequest([
                'parent'      => $parent,
                'data_source' => $dataSource,
            ]);
            $created = $client->createDataSource($request);
            $name    = $created->getName();
        }

        $settings['data_source_name'] = $name;
        update_option('jra_merchant_settings', $settings, false);

        return $name;
    }

    /**
     * Clear the cached primary data source name so the next sync re-resolves it
     * via getOrCreateDataSourceName(). Needed after fixing the Input::API filter
     * on a site that had already cached a wrong (FILE/UI/AUTOFEED-type) data
     * source from before that fix — upserts to that account otherwise keep
     * failing with the same stale, wrong data source forever.
     */
    public function clearDataSourceName(): void {
        $settings = $this->getSettings();
        unset($settings['data_source_name']);
        update_option('jra_merchant_settings', $settings, false);
    }

    /**
     * Revoke the Google token and clear all stored token data.
     */
    public function disconnect(): void {
        $tokens = $this->getTokens();

        // Best-effort revocation — token may already be expired
        try {
            if (!empty($tokens['access_token_encrypted'])) {
                $this->client->setAccessToken([
                    'access_token' => $this->encryption->decrypt($tokens['access_token_encrypted']),
                ]);
                $this->client->revokeToken();
            }
        } catch (Exception $e) {
            // ignore
        }

        update_option('jra_merchant_tokens', [
            'connected'               => false,
            'access_token_encrypted'  => '',
            'refresh_token_encrypted' => '',
            'token_expires'           => '',
            'last_error'              => '',
            'last_synced_at'          => '',
        ], false);
    }

    // -------------------------------------------------------------------------
    // Token helpers
    // -------------------------------------------------------------------------

    /** @return array<string,mixed> */
    public function getTokens(): array {
        return (array) get_option('jra_merchant_tokens', []);
    }

    public function getMerchantId(): string {
        return (string)($this->getSettings()['merchant_id'] ?? '');
    }

    public function getLastSyncedAt(): ?DateTime {
        $tokens = $this->getTokens();
        $value  = $tokens['last_synced_at'] ?? '';
        if ($value === '') {
            return null;
        }
        $dt = DateTime::createFromFormat('Y-m-d H:i:s', $value, new DateTimeZone('UTC'));
        return $dt instanceof DateTime ? $dt : null;
    }

    public function updateLastSyncedAt(): void {
        $tokens                   = $this->getTokens();
        $tokens['last_synced_at'] = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');
        update_option('jra_merchant_tokens', $tokens, false);
    }

    public function getLastError(): string {
        return (string)($this->getTokens()['last_error'] ?? '');
    }

    // -------------------------------------------------------------------------
    // Private helpers
    // -------------------------------------------------------------------------

    private function buildClient(): Google\Client {
        $client = new Google\Client();
        $client->setScopes(['https://www.googleapis.com/auth/content']);
        $client->setAccessType('offline');
        $client->setPrompt('consent');

        if ($this->hasSettings()) {
            $s = $this->getSettings();
            $client->setClientId($this->encryption->decrypt($s['client_id_encrypted']));
            $client->setClientSecret($this->encryption->decrypt($s['client_secret_encrypted']));
        }

        return $client;
    }

    private function storeTokens(array $token): void {
        $expires = new DateTime('now', new DateTimeZone('UTC'));
        $expires->modify('+' . ($token['expires_in'] ?? 3600) . ' seconds');

        $existing = $this->getTokens();

        $existing['access_token_encrypted'] = $this->encryption->encrypt($token['access_token']);
        $existing['token_expires']          = $expires->format('Y-m-d H:i:s');
        $existing['connected']              = true;
        $existing['last_error']             = '';

        if (!empty($token['refresh_token'])) {
            $existing['refresh_token_encrypted'] = $this->encryption->encrypt($token['refresh_token']);
        }

        update_option('jra_merchant_tokens', $existing, false);
    }

    private function markDisconnected(string $error): void {
        $tokens              = $this->getTokens();
        $tokens['connected'] = false;
        $tokens['last_error'] = $error;
        update_option('jra_merchant_tokens', $tokens, false);
    }

    private function getRedirectUri(): string {
        // Must be registered exactly as-is in the Google Cloud Console
        return admin_url('admin-post.php') . '?action=jra_merchant_oauth_callback';
    }
}
