Jake Vanderwerf
10 days ago de317675a8069b747cb253ba3e2b5dc394ca36ef
inc/integrations/Integrations.php
@@ -2,14 +2,10 @@
namespace JVBase\integrations;
use Exception;
use JVBase\managers\CacheManager;
use JVBase\managers\Cache;
use JVBase\managers\UploadManager;
use JVBase\meta\MetaManager;
use JVBase\managers\ErrorHandler;
use JVBase\registrar\Registrar;
use WP_Error;
use WP_Post;
use WP_REST_Request;
use WP_REST_Response;
if (!defined('ABSPATH')) {
   exit;
@@ -26,424 +22,76 @@
 */
abstract class Integrations
{
   use _Base;
   /**
    * API Configuration
    * These properties define how the integration connects to external services
    */
   protected string $service_name;      // Unique identifier for this service (e.g., 'square', 'facebook')
   protected string|array $apiBase = ''; // Base URL(s) for API endpoints. Array format: ['base' => '', 'auth' => '']
   protected array $apiEndpoints = [];   // Valid endpoint paths for this service
   protected string $apiVersion = '';     // API version string (e.g., 'v2', '2024-01-01')
   protected int $refresh_interval = 0; //seconds before expiry to refresh tokens. 0 to disable
   /**
    * OAuth Configuration
    * Set these properties if your service uses OAuth authentication
    */
   protected bool $isOAuthService = false;
   protected array $oauth = [
      'authorize' => '',    // OAuth authorization endpoint
      'token' => '',        // Token exchange endpoint
      'revoke' => '',       // Token revocation endpoint (optional)
      'scopes' => [],       // Required OAuth scopes
      'redirect_uri' => '', // OAuth callback URL
   ];
   /**
    * Display Properties
    * Used for UI rendering in admin interfaces
    */
   public string $title;  // Human-readable service name (e.g., 'Google My Business')
   public string $icon;   // Phosphoricons icon slug
   /**
    * Credentials & State
    */
   protected array $credentials = [];      // Service credentials (API keys, tokens, etc.)
   protected ?int $userID = null;          // User context for user-specific integrations
   protected array $fields = [];       // The fields to generate that become credentials
   protected array $advanced = [];  // The fields that are optional settings
   protected array $defaults = [];     // Default overrides
   protected string $defaultContent = 'post'; //Default integration content type, is none is set. MUST EXIST as array key in $this->>getContentTypes
   protected array $instructions = []; // Each item in array becomes an item in the list of instructions
   protected bool $supportsWebp = true;
   /**
    * Client Management
    * For services that benefit from persistent connections
    */
   protected ?object $client = null;               // Persistent client instance
   protected array $actions = [
      'save_credentials'   => 'saveCredentials',
      'clear_credentials'  => 'deleteCredentials',
      'clear_cache'     => 'clearCache',
      'test_connection' => 'testConnection',
   ];
   protected array $buttons = [
      'save_credentials' => 'Save Changes',
      'clear_credentials' => 'Clear Credentials',
      'clear_cache'=> 'Clear Cache',
      'test_connection' => 'Test Connection',
   ];
   protected array $allowedContent = [];
   /**
    * Caching Configuration
    */
   protected ?string $cacheName = null;
   protected CacheManager $cache;
   protected array $cacheStrategy = [
      'aggressive' => 3600,  // 1 hour for stable data (e.g., profile info)
      'moderate' => 300,     // 5 minutes for semi-dynamic data (e.g., posts)
      'minimal' => 60,       // 1 minute for frequently changing data
      'none' => 0           // No caching for real-time data
   ];
   protected int $ttl = 3600; // Default cache TTL in seconds
   /**
    * Rate Limiting Configuration
    * Override these values based on service-specific limits
    */
   protected array $rate_limits = [
      'per_second' => 2,
      'per_minute' => 30,
      'per_hour' => 1000
   ];
   protected array $oauth_rate_limits = [
         'per_second' => 1,    // Max 1 OAuth request per second
         'per_minute' => 10,   // Max 10 OAuth requests per minute
         'per_hour' => 100,    // Max 100 OAuth requests per hour
      ];
   protected array $request_history = [];
   protected int $max_history_size = 100;
   /**
    * Post Syncing Capabilities
    * Define what sync operations this integration supports
    */
   protected array $canSync = [
      'initial' => false,    // Can share new posts to the service
      'update' => false,     // Can update already-shared posts
      'delete' => false,     // Can remove posts from the service
   ];
   protected array $syncPostTypes = []; // Post types that can be synced (e.g., ['artwork', 'tattoo']): usually built by querying the global JVB_CONTENT if the integration name exists as a key in [ 'integrations' => []]
   protected array $syncTaxonomies = []; // Post types that can be synced (e.g., ['artwork', 'tattoo']): usually built by querying the global JVB_CONTENT if the integration name exists as a key in [ 'integrations' => []]
   protected array $contentTypes = [];   // Integration's available content types. Set by child classes' getContentTypes
   protected bool $has_content = false; // Whether integration has content that can sync
   /**
    * Error Handling Configuration
    */
   protected array $lastError = [];
   protected int $maxRetries = 3;
   protected array $retryDelays = [1, 2, 5]; // Exponential backoff in seconds
   /**
    * Validation Rules
    * Define validation rules for credentials and data
    */
   protected array $validationRules = [];
   /**
    * Error tracking properties
    */
   protected array $error_stats = [
      'total_errors' => 0,
      'consecutive_errors' => 0,
      'last_success' => null,
      'error_types' => []
   ];
   protected ?ErrorHandler $errorHandler = null;
   protected int $error_threshold = 5; // Consecutive errors before marking unhealthy
   protected bool $is_healthy = true;
   protected bool $handleWebhooks = false;
   public function __construct(?int $userID = null)
   protected function __construct(?int $userID = null)
   {
      $this->cacheName = $this->cacheName ?: $this->service_name;
      $this->userID = $userID;
      $this->cache = CacheManager::for('integrations_' . $this->cacheName, $this->ttl);
      // Load error stats from cache
      $this->loadErrorStats();
      $this->cache = Cache::for('integrations_' . $this->cacheName);
      $this->getPostTypes();
      $this->getTaxonomies();
      $this->setContentTypes();
      $this->getUsers();
      if (method_exists($this, 'setContentTypes')) {
         $this->setContentTypes();
      }
      $this->registerHooks();
      if ($this->isOAuthService) {
         $this->actions['get_oauth_url'] = 'getOAuthUrlAction';
         $this->actions['connect_oauth'] = 'handleOAuthConnect';
            $this->actions['disconnect_oauth'] = 'handleOAuthDisconnect';
         $this->actions['refresh_token'] = 'refreshOAuthToken';
         $this->buttons['refresh_token'] = 'Refresh OAuth Connection';
         $this->buttons['disconnect_oauth'] = 'Disconnect OAuth';
      }
      if ($this->handleWebhooks) {
         $this->advanced['webhook_signature_key'] = [
            'label' => 'Webhook Signature Key',
            'type' => 'text',
            'subtype' => 'password',
            'placeholder' => 'Enter webhook signature key',
            'hint' => 'Used to verify webhook authenticity (recommended)'
         ];
      if (method_exists($this, 'setQueueTypes')) {
         $this->setQueueTypes();
      }
      add_filter('jvbShouldRenderMeta', [$this, 'checkRenderField'], 10, 4);
   }
      $this->initializeRateLimiters();
   protected function setContentTypes():void
   {
   }
   public function getContentTypes(string $type):array
   {
      return (array_key_exists($type, $this->contentTypes)) ? $this->contentTypes[$type] : $this->contentTypes;
   }
   public function checkRenderField($shouldRender, $name, $type, $objectType):bool
   {
      if ($type !== 'form') {
         return $shouldRender;
      if (method_exists($this, 'initializeActions')) {
         $this->initializeActions();
      }
      if (!$this->isSetUp() && str_contains($name, $this->service_name)) {
         return false;
      if (method_exists($this, 'addOAuthActions')) {
         $this->addOAuthActions();
      }
      return $shouldRender;
   }
   public function handleOAuthConnect(): array
   {
      if (!$this->isOAuthService) {
         return ['success' => false, 'message' => 'Not an OAuth service'];
      }
      // This would typically redirect to OAuth provider
      // Child classes can override for specific behavior
      $auth_url = $this->getOAuthUrl();
      if ($auth_url) {
         return [
            'success' => true,
            'redirect' => $auth_url,
         ];
      }
      return ['success' => false, 'message' => 'Failed to generate OAuth URL'];
   }
   /**
    * Check if OAuth token is valid and not expired
    * @return bool
    */
   public function isOAuthValid(): bool
   {
      if (!$this->isOAuthService) {
         return false;
      }
      // Check if we have tokens
      if (empty($this->credentials['access_token'])) {
         return false;
      }
      // Check token expiry if stored
      if (!empty($this->credentials['expires_at'])) {
         $expires_at = intval($this->credentials['expires_at']);
         if ($expires_at <= time()) {
            // Token expired, try to refresh
            if (!empty($this->credentials['refresh_token'])) {
               return $this->refreshOAuthToken();
            }
            return false;
         }
      }
      // For services without expiry info, do a test API call
      return true;
//    return $this->testConnection();
   }
   public function getOAuthUrlAction(array $data = []): array
   {
      if (!$this->isOAuthService) {
         return ['success' => false, 'message' => 'Not an OAuth service'];
      }
      $return_url = $data['return_url'] ?? null;
      $auth_url = $this->getOAuthUrl($return_url);
      if ($auth_url) {
         return [
            'success' => true,
            'auth_url' => $auth_url,
            'popup' => true
         ];
      }
      return ['success' => false, 'message' => 'Failed to generate OAuth URL'];
   }
   protected function getPostTypes(): void
   {
      $key = BASE . $this->service_name . '_sync_post_types';
      $postTypes = get_option($key, false);
      if (!$postTypes) {
         $postTypes = [];
      $this->syncPostTypes = Registrar::withIntegration($this->service_name, 'post');
   }
         // Get from JVB_CONTENT
         foreach (JVB_CONTENT as $type => $config) {
            if (array_key_exists('integrations', $config) && array_key_exists($this->service_name, $config['integrations'])) {
               $postTypes[] = $type;
            }
         }
         update_option($key, $postTypes);
      }
      $this->syncPostTypes = $postTypes;
   protected function getUsers():void
   {
      $this->syncUsers = Registrar::withIntegration($this->service_name, 'user');
   }
   protected function getTaxonomies():void
   {
      $key = BASE . $this->service_name . '_sync_taxonomies';
      $taxonomies = get_option($key, false);
      if (!$taxonomies) {
         // Combine both content and taxonomy filtering
         $taxonomies = [];
         // Get from JVB_TAXONOMY (content taxonomies)
         foreach (JVB_TAXONOMY as $type => $config) {
            if (jvbCheck('is_content', $config) &&
               isset($config['integrations'][$this->service_name])) {
               $taxonomies[] = $type;
            }
         }
         update_option($key, $taxonomies);
      }
      $this->syncTaxonomies = $taxonomies;
   }
   protected function getRedirectUri(): string
   {
//    if (!empty($this->oauth['redirect_uri'])) {
//       return $this->oauth['redirect_uri'];
//    }
      if ($this->isOAuthService) {
         return admin_url('admin-ajax.php?action=' . BASE . $this->service_name . '_oauth_callback');
      }
      // Changed from admin-ajax.php to REST endpoint
      return rest_url('jvb/v1/oauth/callback?service=' . $this->service_name);
   }
   /**
    * Used by IntegrationsRoutes.php
    * @param string $code
    * @param string $state
    * @return array
    */
   public function handleOAuthCode(string $code, string $state): array
   {
      try {
         $this->loadCredentials();
         $tokens = $this->exchangeOAuthCode($code);
         if (!$tokens) {
            return ['success' => false, 'message' => 'Failed to exchange authorization code'];
         }
         $credentials = array_merge($this->credentials, $tokens);
         $credentials = $this->addCredentialData($credentials, $tokens);
         $saved = $this->saveCredentials($credentials, false);
         if ($saved) {
            return ['success' => true, 'message' => 'Successfully connected'];
         }
         return ['success' => false, 'message' => 'Failed to save credentials'];
      } catch (Exception $e) {
         $this->logError('OAuth code exchange failed', ['error' => $e->getMessage()]);
         return ['success' => false, 'message' => 'OAuth error: ' . $e->getMessage()];
      }
   }
   /**
    * @param string $action
    * @param mixed|null $data
    * @return array [
    *       'success'   => {bool},
    *       'message'   => {string} optional message,
    *    'data'      => {mixed} optional data to return
    * ]
    */
   public function processAction(string $action, mixed $data = null):array
   {
      if (!$this->checkCapabilities($action, $this->userID)) {
         $this->logError('User cannot perform this action');
         return [
            'success'   => false,
            'message'   => 'Insufficient permissions'
         ];
      }
      if (!array_key_exists($action, $this->actions)) {
         $this->logError('Attempted action not set up', [
            'action' => $action,
            'stored' => $this->actions,
            'service'   => $this->service_name,
            'method' => 'processAction'
         ]);
         return [
            'success'   => false,
            'message'   => 'Invalid action'
         ];
      }
      if (!method_exists($this, $this->actions[$action])) {
         $this->logError('Attempted method not set up', [
            'attempted' => $this->actions['action'],
            'service'   => $this->service_name,
            'method' => 'processAction'
         ]);
         return [
            'success'   => false,
            'message'   => 'Action not set up'
         ];
      }
      $method = $this->actions[$action];
      // Log the action for debugging
      $this->logDebug("Processing action", [
         'action' => $action,
         'method' => $method,
         'data' => $data
      ]);
      if ($data !== null) {
         $result = $this->$method($data);
      } else {
         $result =  $this->$method();
      }
      error_log('Action result: '.print_r($result, true));
      if (is_wp_error($result)) {
         return [
            'success' => false,
            'message' => $result->get_error_message()
         ];
      }
      return is_array($result) ? $result : ['success' => true, 'data' => $result];
      $this->syncTaxonomies = Registrar::withIntegration($this->service_name, 'term');
   }
   /*********************************************************************
@@ -462,22 +110,6 @@
    */
   abstract protected function initialize(): void;
   /**
    * Get request headers for API calls
    *
    * Return an associative array of headers required for API requests.
    * Common headers include Authorization, Content-Type, Accept, etc.
    *
    * Example:
    * return [
    *     'Authorization' => 'Bearer ' . $this->credentials['access_token'],
    *     'Content-Type' => 'application/json',
    *     'Accept' => 'application/json',
    * ];
    *
    * @return array Associative array of header names and values
    */
   abstract protected function getRequestHeaders(): array;
   /**
    * Render the connection settings form
@@ -500,46 +132,33 @@
    * OPTIONAL OVERRIDE METHODS - IMPLEMENT AS NEEDED
    *********************************************************************/
   /**
    * Save credentials using CredentialsManager
    * Save credentials using Auth
    */
   public function saveCredentials(array $credentials, bool $test = true):array
   {
      try {
         // Process and validate credentials
         if (!$this->validateCredentials($credentials)) {
            $this->logError('Invalid credentials');
            return [
               'success'   => false,
               'message'   => 'Credentials not formatted correctly'
            ];
            $this->logError('saveCredentials', 'Invalid credentials');
            return $this->response(false, 'Credentials not formatted correctly');
         }
         //Merge the new credentials with the old ones (new stuff overriding any old stuff)
         $old = $this->getCredentials();
         $credentials = array_merge($old, $credentials);
         // Temporarily set credentials for testing
         $this->credentials = $credentials;
         $this->initialize();
         // Test the connection before saving
         if ($test) {
            if (!$this->testConnection(true)) {
               $this->logError('Connection test failed');
               // Revert to old credentials
               $this->credentials = $old;
               $this->initialize();
               $this->logError('saveCredentials', 'Connection test failed');
               return [
                  'success' => false,
                  'message' => 'Connection failed. Please check your credentials.',
                  'test_failed' => true
               ];
               return $this->response(false, 'Connection failed. Please check your credentials');
            }
         }
         // Connection successful, save credentials
         $stored = CredentialsManager::getInstance()->storeCredentials(
         $stored = Auth::getInstance()->storeCredentials(
            $this->service_name,
            $credentials,
            $this->userID
@@ -549,31 +168,18 @@
            $this->updateLastTestedTime();
            $this->clearCache();
            return [
               'success' => true,
               'message' => 'Credentials validated and saved successfully',
               'reload' => true
            ];
            return $this->response(true, 'Credentials validated and saved successfully');
         }
         return [
            'success' => false,
            'message' => 'Failed to save credentials to database'
         ];
         return $this->response(false, 'Failed to save credentials to database');
      } catch (\Exception $e) {
         // Revert to old credentials on any error
         $old = CredentialsManager::getInstance()->getCredentials(
         $old = Auth::getInstance()->getCredentials(
            $this->service_name,
            $this->userID
         );
         $this->credentials = $old;
         $this->initialize();
         return [
            'success' => false,
            'message' => 'Validation error: ' . $e->getMessage()
         ];
         return $this->response(false, 'Validation error: '.print_r($e->getMessage(), true));
      }
   }
@@ -582,10 +188,8 @@
    */
   public function deleteCredentials():array
   {
      $success = CredentialsManager::getInstance()->deleteCredentials($this->service_name, $this->userID);
      return [
         'success'   => $success
      ];
      $success = Auth::getInstance()->deleteCredentials($this->service_name, $this->userID);
      return $this->response(true, 'Deleted successfully');
   }
   /**
    * Validate credentials before storing
@@ -655,9 +259,7 @@
      }
      try {
         error_log('Credentials to save: '.print_r($this->credentials, true));
         if ($this->isOAuthService && !$this->hasOAuthCredentials()){
            error_log('Just saving credentials, we don\'t have OAuth setup yet...');
            //If this is an OAuth service, we might only be saving the app credentials first
            $result = true;
         } else {
@@ -676,9 +278,9 @@
   protected function clearCache():array
   {
      $success = $this->cache->clear();
      $this->cache->flush();
      return [
         'success'   => $success,
         'success'   => true,
      ];
   }
@@ -714,316 +316,6 @@
   /******************************************************************
      POST SYNC
    ******************************************************************/
   /**
    * Handle post save for syncing
    *
    * Override to implement custom sync logic when posts are saved.
    * Check the $settings array for post type specific configuration.
    *
    * @param int $postID The post ID
    * @param WP_Post $post The post object
    * @param bool $update Whether this is an update
    * @param array $settings Post type integration settings
    * @return void
    */
   protected function handleTheSavePost(int $postID, WP_Post $post, bool $update, array $settings): void
   {
      // Override in child classes that support post syncing
      // Example implementation:
      /*
      $fields = $this->getSyncFields($postID, 'post', ['schedule_' . $this->service_name]);
      $options = $fields['schedule_' . $this->service_name] !== ''
         ? ['scheduled' => strtotime($fields['schedule_' . $this->service_name])]
         : [];
      $this->queueOperation(
         'sync_post',
         ['post_id' => $postID, 'is_update' => $update],
         $options
      );
      */
   }
   /*********************************************************************
    * API REQUEST METHODS
    *********************************************************************/
   /**
    * Make an API request with automatic retry and error handling
    * @param string $method
    * @param string $endpoint
    * @param array $data
    * @param string|null $baseKey array key for the $this->apiEndpoints
    * @param array $options
    * @return WP_Error|array
    */
   protected function makeRequest(
      string $method,
      string $endpoint,
      array  $data = [],
      ?string $baseKey = null,
      array  $options = []
   ): array|WP_Error
   {
      $this->ensureInitialized();
      if (!$this->isSetUp()){
         $this->logError('Connection not setup for '.$this->service_name, [
            'method' => 'makeRequest',
         ]);
         return new WP_Error('Error', 'Connection is not set up to make request');
      }
      // Rate limiting check
      if (!$this->checkRateLimit()) {
         return new WP_Error('rate_limit', 'Rate limit exceeded. Please try again later.');
      }
      // Debug: Check if credentials are loaded
      error_log('['.$this->service_name.'] Make Request - Credentials loaded: ' . (!empty($this->credentials) ? 'Yes' : 'No'));
      error_log('With Credentials: '.print_r($this->credentials, true));
      $attempt = 0;
      $lastError = null;
      while ($attempt < $this->maxRetries) {
         try {
            $this->logDebug('[Integrations] Making request to '.$this->service_name);
            $result = $this->executeRequest($method, $endpoint, $data, $baseKey, $options);
            if (!$result) {
               return new WP_Error('Request Error');
            }
            $this->recordRequest($method, $endpoint);
            $this->logDebug('[Integrations]', [
               'response' => $result
            ]);
            // Reset error stats on success
            $this->resetErrorStats();
            return $result;
         } catch (Exception $e) {
            $lastError = $e;
            $this->recordRequest($method, $endpoint);
            $attempt++;
            // Retry with backoff for server errors
            if ($attempt < $this->maxRetries && !$this->isClientError($e)) {
               $delay = pow(2, $attempt) * 1000000; // 2^attempt seconds in microseconds
               $jitter = rand(0, $delay * 0.3); // Add 0-30% jitter
               usleep($delay + $jitter);
            } else {
               break;
            }
         }
      }
      $this->logError('API request failed after retries', [
         'method' => $method,
         'endpoint' => $endpoint,
         'attempts' => $attempt,
         'error' => $lastError ? $lastError->getMessage() : 'Unknown error'
      ]);
      return new WP_Error('api_error', $lastError ? $lastError->getMessage() : 'API request failed');
   }
   /**
    * Execute the actual request
    */
   protected function executeRequest(
      string $method,
      string $endpoint,
      array  $data = [],
      ?string $baseKey = null,
      array  $options = []
   ): ?array
   {
      $args = $this->buildRequestArgs($method, $data, $options);
      $url = $this->getApiUrl($endpoint, $baseKey);
      if (!$url) {
         return null;
      }
      $this->logDebug("$method request to: $url: ".print_r($args, true));
      // Make the request
      $response = match($method) {
         'GET' => wp_remote_get($url, $args),
         'POST' => wp_remote_post($url, $args),
         'PUT', 'PATCH', 'DELETE' => wp_remote_request($url, array_merge($args, ['method' => $method])),
         default => null
      };
      if (!$response) {
         $this->logError("Unsupported HTTP method $method for $this->service_name");
         return null;
      }
      if (is_wp_error($response)) {
         $this->logError("Error for: $this->service_name in executeRequest", ['message' => $response->get_error_message()]);
         return null;
      }
      $response_code = wp_remote_retrieve_response_code($response);
      $body = wp_remote_retrieve_body($response);
      // Handle 401 - try to refresh token and retry once
      if ($response_code === 401 && $this->isOAuthService && !empty($this->credentials['refresh_token'])) {
         // Avoid infinite retry loop - only retry once
         static $retry_count = 0;
         if ($retry_count === 0) {
            $retry_count++;
            $this->logDebug('Got 401, attempting token refresh...');
            if ($this->refreshOAuthToken()) {
               $this->logDebug('Token refreshed successfully, retrying request...');
               // Rebuild request args with new token
               $args = $this->buildRequestArgs($method, $data, $options);
               // Retry the request
               $response = match($method) {
                  'GET' => wp_remote_get($url, $args),
                  'POST' => wp_remote_post($url, $args),
                  'PUT', 'PATCH', 'DELETE' => wp_remote_request($url, array_merge($args, ['method' => $method])),
                  default => null
               };
               if ($response && !is_wp_error($response)) {
                  $response_code = wp_remote_retrieve_response_code($response);
                  $body = wp_remote_retrieve_body($response);
               }
            }
            $retry_count = 0; // Reset for next request
         }
      }
      if ($response_code >= 400) {
         $this->handleApiError($response_code, $body, $endpoint);
      }
      $decoded = json_decode($body, true);
      if (json_last_error() !== JSON_ERROR_NONE) {
         return ['raw_response' => $body];
      }
      $this->updateLastTestedTime();
      return $decoded;
   }
   /**
    * GET request with caching
    */
   protected function getRequest(
      string $endpoint,
      array  $params = [],
      ?string $baseKey = null,
      string $cacheStrategy = 'moderate',
      bool   $force = false
   ): ?array
   {
      $cacheKey = $this->buildCacheKey('GET', $endpoint, $params);
      $ttl = $this->cacheStrategy[$cacheStrategy] ?? $this->ttl;
      if (!$force && $ttl > 0) {
         $cached = $this->cache->get($cacheKey);
         if ($cached !== false) {
            $this->logDebug("Cache hit for: $cacheKey");
            return $cached;
         }
      }
      $result = $this->makeRequest('GET', $endpoint, $params, $baseKey);
      // Only cache successful responses (not WP_Error and not error objects)
      if ($result && !is_wp_error($result) && !$this->isErrorResponse($result) && $ttl > 0) {
         $this->cache->set($cacheKey, $result, $ttl);
      }
      return $result;
   }
   /**
    * Check if response contains an error
    * Override in child classes for service-specific error detection
    */
   protected function isErrorResponse(array $response): bool
   {
      // Common error patterns across APIs
      return isset($response['error'])
         || isset($response['errors'])
         || isset($response['error_description']);
   }
   /**
    * POST request
    */
   protected function postRequest(string $endpoint, array $data = [], ?string $baseKey = null): ?array
   {
      return $this->makeRequest('POST', $endpoint, $data, $baseKey);
   }
   /**
    * PUT request
    */
   protected function putRequest(string $endpoint, array $data = [], ?string $baseKey = null): ?array
   {
      return $this->makeRequest('PUT', $endpoint, $data, $baseKey);
   }
   /**
    * DELETE request
    */
   protected function deleteRequest(string $endpoint, array $data = [], ?string $baseKey = null): ?array
   {
      return $this->makeRequest('DELETE', $endpoint, $data, $baseKey);
   }
   /**
    * PATCH request
    */
   protected function patchRequest(string $endpoint, array $data = [], ?string $baseKey = null): WP_Error|array
   {
      return $this->makeRequest('PATCH', $endpoint, $data, $baseKey);
   }
   /**
    * Build request arguments
    */
   protected function buildRequestArgs(string $method, array $data, array $options): array
   {
      $args = [
         'method' => $method,
         'headers' => $this->getRequestHeaders(),
         'timeout' => $options['timeout'] ?? 30,
         'sslverify' => $options['sslverify'] ?? true,
         'redirection' => $options['redirection'] ?? 5,
         'blocking' => $options['blocking'] ?? true,
         'compress' => $options['compress'] ?? true,
         'decompress' => $options['decompress'] ?? true,
      ];
      if ($method === 'GET' && !empty($data)) {
         // Add query parameters for GET requests
         $args['body'] = $data;
      } elseif (!empty($data)) {
         $content_type = $args['headers']['Content-Type'] ?? 'application/json';
         if (str_contains($content_type, 'application/json')) {
            $args['body'] = json_encode($data);
         } elseif (str_contains($content_type, 'application/x-www-form-urlencoded')) {
            $args['body'] = http_build_query($data);
         } else {
            $args['body'] = $data;
         }
      }
      return $args;
   }
   /*********************************************************************
    * SYNC METHODS
@@ -1044,11 +336,8 @@
   ):bool {
      $queue = JVB()->queue();
      // Add service prefix to operation type
      $operation_type = strtolower($this->service_name) . '_' . $type;
      $queued =  $queue->queueOperation(
         $operation_type,
         $type,
         $this->userID ?? 0,
         array_merge($data, ['service' => $this->service_name, 'user' => $this->userID??0]),
         array_merge([
@@ -1090,117 +379,25 @@
    *********************************************************************/
   /**
    * Get cache duration based on endpoint
    *
    * @param string $endpoint The API endpoint
    * @return int Cache duration in seconds
    */
   protected function getCacheDuration(string $endpoint): int
   {
      // Override in child classes for endpoint-specific caching
      // Example: user profiles = aggressive, posts = moderate, analytics = minimal
      return $this->ttl;
   }
   /**
    * Check if we're within rate limits
    *
    * @return bool True if within limits
    */
   protected function checkRateLimit(): bool
   {
      $now = time();
      // Clean old history
      $this->request_history = array_filter($this->request_history, function($timestamp) use ($now) {
         return ($now - $timestamp) < 3600; // Keep last hour
      });
      // Trim to max size
      if (count($this->request_history) > $this->max_history_size) {
         $this->request_history = array_slice($this->request_history, -$this->max_history_size);
      }
      // Check limits
      $recent_counts = [
         'second' => 0,
         'minute' => 0,
         'hour' => count($this->request_history)
      ];
      foreach ($this->request_history as $timestamp) {
         if ($now - $timestamp <= 1) $recent_counts['second']++;
         if ($now - $timestamp <= 60) $recent_counts['minute']++;
      }
      if ($recent_counts['second'] >= $this->rate_limits['per_second']) return false;
      if ($recent_counts['minute'] >= $this->rate_limits['per_minute']) return false;
      if ($recent_counts['hour'] >= $this->rate_limits['per_hour']) return false;
      return true;
   }
   protected function checkOAuthRateLimit(): bool
   {
      $cache_key = 'oauth_rate_limit_' . $this->service_name . '_' . $this->userID;
      $attempts = get_transient($cache_key) ?: [];
      $now = time();
      // Clean old attempts
      $attempts = array_filter($attempts, function($timestamp) use ($now) {
         return ($now - $timestamp) < 3600; // Keep last hour
      });
      // Check per-minute limit
      $recent = array_filter($attempts, function($timestamp) use ($now) {
         return ($now - $timestamp) < 60;
      });
      if (count($recent) >= 5) { // Max 5 OAuth attempts per minute
         return false;
      }
      // Add current attempt
      $attempts[] = $now;
      // Save updated attempts
      set_transient($cache_key, $attempts, 3600);
      return true;
   }
   /**
    * Record a request for rate limiting
    *
    * @param string $method HTTP method
    * @param string $endpoint API endpoint
    * @return void
    */
   protected function recordRequest(string $method, string $endpoint): void
   {
      $this->request_history[] = time();
   }
   /**
    * Register WordPress hooks based on capabilities
    */
   protected function registerHooks(): void
   {
      add_filter(BASE . 'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
      add_action( 'wp_ajax_'.BASE.$this->service_name.'_oauth_callback', [$this, 'handleAjaxResponse'] );
      add_action( 'wp_ajax_nopriv_'.BASE.$this->service_name.'_oauth_callback', [$this, 'handleAjaxResponse'] );
      if ($this->handleWebhooks){
      //Handled by IntegrationExecutor now
//    add_filter(BASE . 'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
      if (method_exists($this, 'registerOAuthCallbacks')) {
         $this->registerOAuthCallbacks();
      }
      if (method_exists($this, 'registerWebhookEndpoint')) {
         $this->registerWebhookEndpoint();
      }
      // Let child classes register additional hooks if needed
      $this->registerAdditionalHooks();
      if (!empty($this->syncPostTypes)) {
         add_action('save_post', [$this, 'handleSavePost'], 20, 3);
         $this->addSavePost();
         add_action('transition_post_status', [$this, 'handlePostStatusTransition'], 10, 3);
         if ($this->canSync['delete']) {
@@ -1213,71 +410,16 @@
            add_action('pre_delete_term', [$this, 'handleDeleteTerm'], 10, 2);
         }
      }
      if (method_exists($this, 'registerQueueTypes')) {
         add_action('init', [$this, 'registerQueueTypes'], 10);
      }
   }
   public function handleAjaxResponse()
   {
      error_log('Ajax Response: '.print_r($_GET, true));
      $code = $_GET['code'];
      $state =  $_GET['state'];
      error_log('OAuth Callback - Code: ' . $code);
      error_log('OAuth Callback - State: ' . $state);
      $state_parts = explode('|', $state);
      $state_key = $state_parts[0] ?? '';
      $user_id = intval($state_parts[1] ?? 0);
      $user_id = ($user_id === 0) ? null : $user_id;
      $return_url = isset($state_parts[2]) ? base64_decode($state_parts[2]) : admin_url('admin.php?page=jvb-integrations');
      error_log('Service: '.print_r($this->service_name, true));
      $state_data = get_transient('oauth_state_' . $state_key);
      error_log('State Data: '.print_r($state_data, true));
      if (!$state_data || $state_data['service'] !== $this->service_name) {
         wp_die('Invalid state parameter', 'OAuth Error');
      }
      // Delete the transient to prevent reuse
      delete_transient('oauth_state_' . $state_key);
      error_log('Return URL: '.print_r($return_url, true));
      // Handle error from OAuth provider
      if (array_key_exists('error', $_GET)) {
         $error_description = $_GET['error_description'] ?? 'Authorization denied';
         wp_redirect(add_query_arg([
            'error' => 'OAuth authorization denied: ' . $error_description
         ], $return_url));
         exit;
      }
      // Get integration instance
      $integration = JVB()->connect($this->service_name, $user_id);
      if (!$integration) {
         wp_die('Invalid service: ' . esc_html($this->service_name), 'OAuth Error');
      }
      // Exchange code for tokens
      $result = $integration->handleOAuthCode($code, $state);
      // Redirect back with result
      if ($result['success']) {
         wp_redirect(add_query_arg([
            'success' => 'Successfully connected to ' . $integration->title
         ], $return_url));
      } else {
         // Handle failure
         $error_message = $result['message'] ?? 'Failed to complete OAuth authorization';
         wp_redirect(add_query_arg([
            'error' => $error_message
         ], $return_url));
      }
      exit;
   }
   /**
    * Handle connection setup and credential storage
    *
@@ -1298,11 +440,11 @@
         }
         // Store credentials
         $this->credentials = array_merge($this->credentials ?? [], $sanitized);
         $this->credentials['last_updated'] = time();
         $credentials = array_merge($this->loadCredentials() ?? [], $sanitized);
         $credentials['last_updated'] = time();
         // Save to database
         $saved = $this->saveCredentials($this->credentials);
         $saved = $this->saveCredentials($credentials);
         if (!$saved) {
            return [
@@ -1335,680 +477,34 @@
      }
   }
   /**
    * Load credentials from secure storage
    */
   protected function loadCredentials(): void
   {
      $this->credentials = CredentialsManager::getInstance()->getCredentials($this->service_name, $this->userID);
   }
   public function getCredentials(): array
   {
      $this->ensureInitialized();
      return $this->credentials;
      return $this->loadCredentials();
   }
   /**
    * Check if integration is properly configured
    */
   public function isSetUp(): bool
   public function hasOAuthCredentials(?int $userID = null): bool
   {
      if (empty($this->credentials)) {
         $this->loadCredentials();
      if ($userID !== $this->userID) {
         $this->switchUser($userID);
      }
      return !empty($this->credentials);
   }
   public function hasOAuthCredentials(): bool
   {
      if (!$this->isOAuthService){
         return false;
      }
      $required = ['client_id', 'client_secret', 'access_token'];
      foreach ($required as $field) {
         if (empty($this->credentials[$field]) || !is_string($this->credentials[$field]) || $this->credentials[$field] === '') {
            return false;
         }
      }
      return true;
      return $this->hasOAuth && $this->hasValidOAuth();
   }
   /**
    * Ensure service is initialized
    * TODO: I don't think this is necessary anymore.
    */
   protected function ensureInitialized(): void
   {
      if (!$this->isSetUp()){
         return;
      }
      if (empty($this->credentials)) {
         $this->loadCredentials();
      }
      if (!empty($this->credentials)) {
         if ($this->isOAuthService && $this->hasOAuthCredentials()) {
            // Check if token is expired first
            if (!$this->isOAuthValid()) {
               $this->logDebug('OAuth token expired, attempting refresh');
               if (!$this->refreshOAuthToken()) {
                  $this->logError('Failed to refresh expired OAuth token');
               }
            }
            // Check if we should proactively refresh (before expiry)
            elseif ($this->shouldRefreshToken()) {
               $this->logDebug('OAuth token should be refreshed proactively');
               if (!$this->refreshOAuthToken()) {
                  $this->logError('Failed to proactively refresh OAuth token');
                  // Not critical - token is still valid
               }
            }
         }
         $this->initialize();
      }
      $this->initialize();
   }
   /**
    * Switch user context
    */
   public function switchUser(int $user_id): void
   {
      if ($this->userID === $user_id) {
         return;
      }
      // Clean up current context
      $this->cleanup();
      // Switch context
      $this->userID = $user_id;
      $this->credentials = [];
      $this->ensureInitialized();
   }
   public function getAsUser(int $user_id) {
      return new $this($user_id);
   }
   /**
    * Clean up resources
    */
   protected function cleanup(): void
   {
      // Clear sensitive data
      $this->credentials = [];
      // Clear request history
      $this->request_history = [];
   }
   /**
    * Destructor - ensure cleanup
    */
   public function __destruct()
   {
      $this->cleanup();
   }
   /***************************************************************
      ERROR HANDLING
    ***************************************************************/
   /**
    * Handle error responses
    */
   protected function handleApiError(int $code, string $body, string $endpoint): void
   {
      $message = "API Error ({$code}): ";
      $decoded = json_decode($body, true);
      // Extract error details
      $error_details = $this->extractErrorDetails($decoded, $body);
      $message .= $error_details['message'];
      // Determine error severity based on HTTP code
      $severity = $this->getErrorSeverity($code);
      // Build comprehensive error context
      $error_context = [
         'service' => $this->service_name,
         'endpoint' => $endpoint,
         'http_code' => $code,
         'error_type' => $this->categorizeApiError($code),
         'error_details' => $error_details,
         'user_id' => $this->userID,
         'request_time' => time(),
         'consecutive_errors' => $this->error_stats['consecutive_errors'],
         'is_oauth' => $this->isOAuthService,
         'api_version' => $this->apiVersion,
         'integration_healthy' => $this->is_healthy
      ];
      // Add rate limit information if present
      if ($code === 429) {
         $error_context['rate_limit'] = $this->extractRateLimitInfo($decoded, $body);
      }
      // Log to ErrorHandler with proper severity
      $this->logError($message, $error_context, $severity);
      // Update error statistics
      $this->updateErrorStats($code, $endpoint);
      // Check if integration should be marked unhealthy
      $this->checkIntegrationHealth();
      // Store last error for debugging
      $this->lastError = [
         'code' => $code,
         'message' => $message,
         'endpoint' => $endpoint,
         'timestamp' => time(),
         'context' => $error_context
      ];
   }
   /**
    * Check if error is a client error (4xx)
    */
   protected function isClientError(Exception $e): bool
   {
      $code = $e->getCode();
      return $code >= 400 && $code < 500;
   }
   /**
    * Get last error details
    */
   public function getLastError(): array
   {
      return $this->lastError;
   }
   /*****************************************************************
      OAUTH
    *****************************************************************/
   /**
    * Check user capabilities for specific actions
    * Can be overridden by child classes for custom logic
    */
   protected function checkCapabilities(string $action, ?int $userID = null): bool
   {
      // Default: require manage_options for admin actions
      $admin_actions = ['disconnect', 'update_credentials', 'clear_cache'];
      if (in_array($action, $admin_actions)) {
         return current_user_can('manage_options');
      }
      // User-specific actions
      return is_user_logged_in();
   }
   /*****************************************************************
      OAUTH
    *****************************************************************/
   protected function initializeOAuth():void
   {
      $this->oauth = array_merge([
         'authorize' => '',
         'token'     => '',
         'revoke' => '',
         'scopes' => [],
         'redirect_uri' => admin_url('admin-ajax.php?action=' . $this->service_name . '_oauth_callback')
      ], $this->oauth);
   }
   /**
    * Get OAuth authorization URL
    */
   public function getOAuthUrl(?string $return_url = null): string
   {
      if (!$this->isOAuthService) {
         return '';
      }
      if (empty($this->credentials)) {
         $this->ensureInitialized();
      }
      if (empty($this->oauth['authorize'])) {
         $this->logError('OAuth authorize URL not configured');
         return '';
      }
      // Build base parameters
      $params = [
         'client_id' => $this->credentials['client_id'] ?? $this->credentials['app_id'] ?? '',
         'redirect_uri' => $this->getRedirectUri(),
         'response_type' => 'code',
         'scope' => implode(' ', $this->oauth['scopes'] ?? [])
      ];
      $state_key = wp_generate_password(32, false);
      $user_id = $this->userID??0;
      // Store state data in transient (expires in 10 minutes)
      set_transient(
         'oauth_state_' . $state_key,
         [
            'service' => $this->service_name,
            'user_id' => $user_id,
            'created' => time()
         ],
         600
      );
      $state_parts = [
         $state_key,
         $user_id
      ];
      // Add return URL if provided
      if ($return_url) {
         $state_parts[] = base64_encode($return_url);
      } else {
         $state_parts[] = base64_encode(admin_url('admin.php?page=jvb-integrations'));
      }
      $params['state'] = implode('|', $state_parts);
      // Allow child classes to modify params (they can override/remove as needed)
      if (method_exists($this, 'addOAuthParams')) {
         $params = $this->addOAuthParams($params);
      }
      $auth_url = $this->oauth['authorize'] . '?' . http_build_query($params);
      // Debug log for troubleshooting
      error_log("Generated OAuth URL for {$this->service_name}: " . $auth_url);
      return $auth_url;
   }
   /**
    * Add service-specific OAuth parameters
    */
   protected function addOAuthParams(array $params): array
   {
      // Override in child classes to add service-specific params
      // e.g., access_type, prompt, etc.
      return $params;
   }
   protected function makeOAuthRequest(string $method, string $url, array $data = []): array|WP_Error
   {
      // Check rate limits before making request
      if (!$this->checkOAuthRateLimit()) {
         // For OAuth, we might want to be more lenient with rate limits
         // since these are critical authentication flows
         $this->logError('OAuth request rate limited, waiting before retry');
         sleep(2); // Brief pause before critical OAuth request
         // Check again after wait
         if (!$this->checkRateLimit()) {
            return new WP_Error(
               'rate_limit',
               'OAuth rate limit exceeded. Please try again in a few moments.'
            );
         }
      }
      // Record the request for rate limiting
      $this->recordRequest($method, $url);
      // Build OAuth-specific headers
      $headers = [
         'Content-Type' => 'application/json',
         'Accept' => 'application/json',
      ];
      // Add API version for services that require it (like Square)
      if (!empty($this->apiVersion)) {
         $headers['Square-Version'] = $this->apiVersion;
      }
      // Build request arguments
      $args = [
         'method' => $method,
         'headers' => $headers,
         'timeout' => 30,
         'sslverify' => true,
         'body' => json_encode($data)
      ];
      // Make the request with retry logic
      $attempt = 0;
      $max_attempts = 2; // Fewer retries for OAuth to avoid code expiry
      while ($attempt < $max_attempts) {
         $attempt++;
         // Make the actual request
         $response = wp_remote_request($url, $args);
         // Check for WordPress errors
         if (is_wp_error($response)) {
            if ($attempt >= $max_attempts) {
               return $response;
            }
            // Wait before retry
            sleep($this->retryDelays[$attempt - 1] ?? 1);
            continue;
         }
         // Get response code and body
         $code = wp_remote_retrieve_response_code($response);
         $body = wp_remote_retrieve_body($response);
         // Parse JSON response
         $data = json_decode($body, true);
         // Check for successful response
         if ($code >= 200 && $code < 300) {
            // Reset error stats on success
            $this->resetErrorStats();
            // Return parsed data
            return $data ?: [];
         }
         // Handle specific OAuth error codes
         if ($code === 401) {
            // Invalid credentials - don't retry
            $this->handleApiError($code, $body, $url);
            return new WP_Error('invalid_credentials', 'Invalid OAuth credentials');
         }
         if ($code === 429) {
            // Rate limited - wait longer before retry
            if ($attempt < $max_attempts) {
               $retry_after = $this->extractRetryAfter($response);
               sleep($retry_after ?: 5);
               continue;
            }
         }
         // Check if we should retry
         if ($attempt >= $max_attempts || $code >= 400 && $code < 500) {
            // Client errors (4xx) typically shouldn't be retried
            $this->handleApiError($code, $body, $url);
            $error_message = $data['error_description'] ??
               $data['error'] ??
               "OAuth request failed with status {$code}";
            return new WP_Error('oauth_error', $error_message, ['code' => $code]);
         }
         // Server error - retry after delay
         sleep($this->retryDelays[$attempt - 1] ?? 1);
      }
      // Shouldn't reach here, but handle it
      return new WP_Error('oauth_error', 'OAuth request failed after retries');
   }
   /**
    * Extract retry-after header from response
    *
    * @param array|WP_Error $response WordPress HTTP response
    * @return int Seconds to wait before retry
    */
   protected function extractRetryAfter($response): int
   {
      if (is_wp_error($response)) {
         return 5;
      }
      $headers = wp_remote_retrieve_headers($response);
      if (isset($headers['retry-after'])) {
         // Could be seconds or HTTP date
         $retry_after = $headers['retry-after'];
         if (is_numeric($retry_after)) {
            return (int) $retry_after;
         }
         // Try to parse as date
         $timestamp = strtotime($retry_after);
         if ($timestamp !== false) {
            return max(0, $timestamp - time());
         }
      }
      return 5; // Default wait time
   }
   /**
    * Exchange OAuth code for tokens
    */
   protected function exchangeOAuthCode(string $code): ?array
   {
      $this->ensureInitialized();
      // Build request data
      $request_data = [
         'client_id' => $this->credentials['client_id'] ?? '',
         'client_secret' => $this->credentials['client_secret'] ?? '',
         'code' => $code,
         'grant_type' => 'authorization_code',
         'redirect_uri' => $this->getRedirectUri()
      ];
      $oauth_endpoint = $this->oauth['token'];
      $response = $this->makeOAuthRequest('POST', $oauth_endpoint, $request_data);
      if (is_wp_error($response)) {
         $this->logError('OAuth token exchange failed', [
            'error' => $response->get_error_message(),
            'code' => $response->get_error_code()
         ]);
         return null;
      }
      // Parse response
      if (isset($response['access_token'])) {
         $expires_in = $response['expires_in'] ?? 2592000; // 30 days default
         return [
            'access_token' => $response['access_token'],
            'refresh_token' => $response['refresh_token'] ?? '',
            'expires_in' => $expires_in,
            'expires_at' => time() + $expires_in, // Calculate expiry timestamp
            'token_type' => $response['token_type'] ?? 'Bearer',
            'merchant_id' => $response['merchant_id'] ?? '',
            'scope' => $response['scope'] ?? ''
         ];
      }
      $this->logError('Failed to obtain access token', ['response' => $response]);
      return null;
   }
   /**
    * Add service-specific credential data
    */
   protected function addCredentialData(array $credentials, array $tokens): array
   {
      // Override in child classes to add service-specific data
      return $credentials;
   }
   /**
    * Check if token should be proactively refreshed
    * Different from isOAuthValid() which checks if token is actually expired
    */
   protected function shouldRefreshToken(): bool
   {
      if (!$this->isOAuthService || $this->refresh_interval === 0) {
         return false;
      }
      // If no expiry info, we can't proactively refresh
      if (empty($this->credentials['expires_at'])) {
         return false;
      }
      $expires_at = intval($this->credentials['expires_at']);
      $time_until_expiry = $expires_at - time();
      // Refresh if we're within the refresh interval window
      return $time_until_expiry > 0 && $time_until_expiry <= $this->refresh_interval;
   }
   /**
    * Get time until token refresh is recommended
    * Useful for displaying in admin UI
    */
   public function getTimeUntilRefresh(): ?int
   {
      if ($this->refresh_interval === 0 || empty($this->credentials['expires_at'])) {
         return null;
      }
      $expires_at = intval($this->credentials['expires_at']);
      $refresh_at = $expires_at - $this->refresh_interval;
      $time_until_refresh = $refresh_at - time();
      return max(0, $time_until_refresh);
   }
   /**
    * Get token freshness status
    * Returns: 'fresh', 'should_refresh', 'expired', or 'no_expiry_info'
    */
   public function getTokenStatus(): string
   {
      if (!$this->isOAuthService) {
         return 'not_oauth';
      }
      if (empty($this->credentials['access_token'])) {
         return 'no_token';
      }
      if (empty($this->credentials['expires_at'])) {
         return 'no_expiry_info';
      }
      $expires_at = intval($this->credentials['expires_at']);
      $now = time();
      if ($expires_at <= $now) {
         return 'expired';
      }
      if ($this->shouldRefreshToken()) {
         return 'should_refresh';
      }
      return 'fresh';
   }
   /**
    * Refresh OAuth token
    */
   protected function refreshOAuthToken(): bool
   {
      if (!$this->isOAuthService || empty($this->credentials['refresh_token'])) {
         return false;
      }
      // Build refresh request data
      $request_data = [
         'client_id' => $this->credentials['client_id'],
         'client_secret' => $this->credentials['client_secret'],
         'refresh_token' => $this->credentials['refresh_token'],
         'grant_type' => 'refresh_token'
      ];
      // Use centralized OAuth request method
      $response = $this->makeOAuthRequest('POST', $this->oauth['token'], $request_data);
      if (is_wp_error($response)) {
         $this->logError('Failed to refresh Square token', [
            'error' => $response->get_error_message()
         ]);
         return false;
      }
      if (isset($response['access_token'])) {
         $this->credentials['access_token'] = $response['access_token'];
         $this->credentials['expires_at'] = time() + ($response['expires_in'] ?? 2592000); // 30 days
         // Note: Square returns the SAME refresh token
         if (isset($response['refresh_token'])) {
            $this->credentials['refresh_token'] = $response['refresh_token'];
         }
         $this->saveCredentials($this->credentials);
         return true;
      }
      return false;
   }
   /**
    * Revoke OAuth access
    */
   public function revokeOAuthAccess(): bool
   {
      if (!$this->isOAuthService || empty($this->oauth['revoke'])) {
         return false;
      }
      if (!empty($this->credentials['access_token'])) {
         wp_remote_post($this->oauth['revoke'], [
            'body' => ['token' => $this->credentials['access_token']]
         ]);
      }
      return CredentialsManager::getInstance()->deleteCredentials($this->service_name, $this->userID);
   }
   public function handleOAuthDisconnect(): array
   {
      try {
         // Revoke the token with Square using centralized request
         if (!empty($this->credentials['access_token'])) {
            $revoke_data = [
               'client_id' => $this->credentials['client_id'] ?? '',
               'access_token' => $this->credentials['access_token']
            ];
            // Make revoke request (ignore response as revoke often returns empty)
            $this->makeOAuthRequest('POST', $this->oauth['revoke'], $revoke_data);
         }
         // Clear stored credentials (preserve app credentials)
         $this->credentials = [
            'client_id' => $this->credentials['client_id'] ?? '',
            'client_secret' => $this->credentials['client_secret'] ?? '',
            'environment' => $this->credentials['environment'] ?? 'sandbox'
         ];
         $this->saveCredentials($this->credentials);
         $this->clearCache();
         return [
            'success' => true,
            'message' => 'Successfully disconnected from Square'
         ];
      } catch (Exception $e) {
         return [
            'success' => false,
            'message' => 'Failed to disconnect: ' . $e->getMessage()
         ];
      }
   }
   /**
    * Generate webhook signature key for services that require it
    * @return string
@@ -2017,686 +513,13 @@
   {
      return wp_generate_password(32, false);
   }
   /**
    * Generate OAuth state parameter
    */
   protected function generateOAuthState(?int $user_id, ?string $return_url = null): string
   {
      $user_id = $user_id ?? 0;
      $state = wp_create_nonce($this->service_name . '_oauth_' . $user_id) . '|' . $user_id;
      if ($return_url) {
         $state .= '|' . base64_encode($return_url);
      }
      return $state;
   }
   protected function getNonce(): string
   {
       return wp_create_nonce($this->service_name . '_oauth_' . $this->userID);
   }
   /**
    * Determine return URL after OAuth
    */
   protected function determineReturnUrl(?int $user_id): string
   {
      if ($user_id > 0) {
         return home_url('/dash/integrations/#' . $this->service_name);
      }
      return admin_url('admin.php?page=jvb-integrations');
   }
   /****************************************************************
      POST SYNC
    ****************************************************************/
   /**
    * Get field mapping for a post type
    */
   protected function getFieldMapping(string $post_type): array
   {
      // Apply filter for custom mapping
      return apply_filters(
         "jvb_{$this->service_name}_field_mapping",
         [],
         $post_type,
         $this
      );
   }
   /**
    * Map WordPress fields to service fields
    */
   protected function mapFieldsToService(int $postID, array $mapping): array
   {
      $meta_manager = new MetaManager($postID, 'post');
      $post = get_post($postID);
      $service_data = [];
      foreach ($mapping as $wp_field => $service_field) {
         $value = null;
         // Check if it's a post field
         if (property_exists($post, $wp_field)) {
            $value = $post->$wp_field;
         } else {
            // It's a meta field
            $value = $meta_manager->getValue($wp_field);
         }
         if ($value !== null && $value !== '') {
            $this->setNestedValue($service_data, $service_field, $value);
         }
      }
      return apply_filters(
         "jvb_{$this->service_name}_mapped_data",
         $service_data,
         $postID,
         $mapping
      );
   }
   /**
    * Set nested array value using dot notation
    */
   protected function setNestedValue(array &$array, string $path, $value): void
   {
      $keys = explode('.', $path);
      $current = &$array;
      foreach ($keys as $i => $key) {
         if ($i === count($keys) - 1) {
            $current[$key] = $value;
         } else {
            if (!isset($current[$key])) {
               $current[$key] = [];
            }
            $current = &$current[$key];
         }
      }
   }
   /**
    * Handle post save
    */
   public function handleSavePost(int $postID, WP_Post $post, bool $update): void
   {
      error_log('Testing For Save Post');
      if (jvbNoSaveIt($postID, $post)) {
         error_log('Excluded by jvbNoSaveIt');
         return;
      }
      if (empty($this->syncPostTypes)) {
         error_log('No Syncable post types');
         return;
      }
      $config = JVB_CONTENT[jvbNoBase($post->post_type)]??null;
      if (!$config) {
         error_log('No Config set');
         return;
      }
      $settings = $config['integrations'][$this->service_name]??null;
      if (!$settings) {
         error_log('No settings');
         return;
      }
      $fields = $this->getSyncFields($postID, 'post', ['schedule_'.$this->service_name]);
      error_log('Fields to check: '.print_r($fields, true));
      if (!$fields['share_to_'.$this->service_name]) {
         return;
      }
      $isShared = isset($fields["_{$this->service_name}_item_id"]);
      if ($update && $isShared && !$fields['_keep_synced_'.$this->service_name]) {
         error_log('Do not keep synced, not syncing with '.$this->service_name);
         return;
      }
      if ($post->post_status !== 'publish' && !$isShared) {
         error_log('Not published and not already shared');
         return;
      }
      error_log('Sending to integration for processing...');
      $this->handleTheSavePost($postID, $post, $update, $settings);
   }
   protected function getSyncFields(int $postID, string $type, array $additional = []):array
   {
      $meta = new MetaManager($postID, $type);
      $fieldsToCheck = [
         'share_to_' . $this->service_name,
         '_keep_synced_' . $this->service_name,
         "_{$this->service_name}_item_id",
         "_{$this->service_name}_last_sync",
         "_{$this->service_name}_shared_at",
         "_{$this->service_name}_sync_status",
         ... $additional
      ];
      return $meta->getAll($fieldsToCheck);
   }
   /**
    * Handle post status transitions
    */
   public function handlePostStatusTransition(string $new_status, string $old_status, WP_Post $post): void
   {
      if (empty($this->syncPostTypes)) {
         return;
      }
      if (!in_array(jvbNoBase($post->post_type), $this->syncPostTypes)) {
         return;
      }
      //Map fields from our custom post types to the fields expected by the integration
      $mappedFields = $this->getFieldMapping($post->post_type);
      $fields = $this->getSyncFields($post->ID, 'post', $mappedFields);
      // Handle unpublish action
      if ($old_status === 'publish' && $new_status !== 'publish') {
         if ($fields["_{$this->service_name}_item_id"] && $this->canSync['delete']) {
            try {
               $this->queueOperation(
                  'delete_post',
                  [
                     $post,
                     $fields["_{$this->service_name}_item_id"]
                  ]
               );
            } catch (Exception $e) {
               $this->logError("Failed to handle unpublish for post {$post->ID}: " . $e->getMessage());
            }
         }
      }
   }
   /**
    * Handle post deletion
    */
   public function handleDeletePost(int $postID): void
   {
      if (!$this->canSync['delete']) {
         return;
      }
      $post = get_post($postID);
      if (!$post || !in_array($post->post_type, $this->syncPostTypes)) {
         return;
      }
      $fields = $this->getSyncFields($postID, 'post');
      if ($fields["_{$this->service_name}_item_id"] !== '') {
         $this->queueOperation(
            'delete_post',
            [
               'post_id'   => $post->ID,
            ]
         );
      }
   }
   protected function handleSaveTerm($term_id, $tt_id, $taxonomy, $update, $args): void
   {
      $noBase = jvbNoBase($taxonomy);
      if (!in_array($noBase, $this->syncTaxonomies)) {
         return;
      }
      // Check if taxonomy is content-type
      $config = JVB_TAXONOMY[$noBase] ?? null;
      if (!$config || !($config['is_content'] ?? false)) {
         return;
      }
      $settings = $config['integrations'][$this->service_name] ?? null;
      if (!$settings) {
         return;
      }
      // Similar sync logic as handleSavePost but for terms
      $this->handleTheTermSave($term_id, $taxonomy, $update, $settings);
   }
   protected function handleTheTermSave($term_id, $taxonomy, $update, $settings) {
   }
   protected function getTermFieldMapping(string $taxonomy): array
   {
      return apply_filters(
         "jvb_{$this->service_name}_term_field_mapping",
         [],
         $taxonomy,
         $this
      );
   }
   /**
    * Share post to external service (override in child classes)
    */
   protected function sharePost(WP_Post $post): ?string
   {
      throw new Exception('sharePost must be implemented by child class');
   }
   /**
    * Update shared post on external service (override in child classes)
    */
   protected function updateSharedPost(WP_Post $post, string $external_id): void
   {
      throw new Exception('updateSharedPost must be implemented by child class');
   }
   /**
    * Remove post from external service (override in child classes)
    */
   protected function unsharePost(WP_Post $post, string $external_id): void
   {
      throw new Exception('unsharePost must be implemented by child class');
   }
   /**
    * Get sync capabilities
    */
   public function getSyncCapabilities(): array
   {
      return $this->canSync;
   }
   /**
    * Check if a specific sync capability is supported
    */
   public function supportsSyncCapability(string $capability): bool
   {
      return $this->canSync[$capability] ?? false;
   }
   /**
    * Get posts shared to this service
    */
   public function getSharedPosts(array $args = []): array
   {
      $defaults = [
         'post_type' => $this->syncPostTypes,
         'meta_query' => [
            [
               'key' => BASE."_{$this->service_name}_item_id",
               'compare' => 'EXISTS'
            ]
         ],
         'posts_per_page' => -1
      ];
      $args = wp_parse_args($args, $defaults);
      return get_posts($args);
   }
   /**
    * Check if post is shared to this service
    */
   public function isPostShared(int $postID): bool
   {
      $external_id = get_post_meta($postID, BASE."_{$this->service_name}_item_id", true);
      return $external_id !== '';
   }
   /**
    * Get external ID for a post
    */
   public function getPostExternalId(int $postID): ?string
   {
      $external_id = get_post_meta($postID, BASE."_{$this->service_name}_item_id", true);
      return $external_id ?: null;
   }
   /*******************************************************************
    * UTILITIES
    *******************************************************************/
   /**
    * Get API URL for endpoint
    */
   protected function getApiUrl(string $endpoint, ?string $baseKey = null): string|false
   {
      if ($this->isOAuthService && in_array($endpoint, $this->oauth)) {
         return $endpoint;
      }
      if (is_array($this->apiBase)) {
         if ($baseKey && array_key_exists($baseKey, $this->apiBase)) {
            $base = $this->apiBase[$baseKey];
         } else {
            $base = ($this->apiBase['base'] ?? reset($this->apiBase));
         }
      } else {
         $base = $this->apiBase;
      }
      if (!$base || $base === '') {
         $this->logError('API base URL not configured for {$this->>service_name}');
         return false;
      }
      // Handle named endpoints
      if (!$this->isValidEndpoint($endpoint)) {
         $this->logError("{$endpoint} is not a valid endpoint for {$this->service_name}");
         return false;
      }
      // Build full URL
      $base = rtrim($base, '/');
      $endpoint = ltrim($endpoint, '/');
      return "{$base}/{$endpoint}";
   }
   /**
    * Check if an endpoint is valid, supporting both exact matches and patterns
    *
    * @param string $endpoint
    * @return bool
    */
   protected function isValidEndpoint(string $endpoint): bool
   {
      // Remove query parameters for validation
      $endpointPath = parse_url($endpoint, PHP_URL_PATH);
      if ($endpointPath === false) {
         $endpointPath = $endpoint;
      }
      foreach ($this->apiEndpoints as $pattern) {
         // Check for exact match first
         if ($endpointPath === $pattern) {
            return true;
         }
         if (str_starts_with($endpointPath, $pattern)) {
            return true;
         }
         // Check if pattern contains wildcards (indicated by square brackets)
         if (strpos($pattern, '[') !== false) {
            // Convert the pattern to a regex
            $regexPattern = '#^' . str_replace(['[^/]+'], ['[^/]+'], $pattern) . '(?:\?.*)?$#';
            if (preg_match($regexPattern, $endpoint)) {
               return true;
            }
         }
      }
      return false;
   }
   /**
    * Build cache key
    */
   protected function buildCacheKey(string $method, string $endpoint, array $params = []): string
   {
      $key_parts = [
         $this->service_name,
         $method,
         $endpoint
      ];
      if ($this->userID) {
         $key_parts[] = "user_{$this->userID}";
      }
      if (!empty($params)) {
         $key_parts[] = md5(serialize($params));
      }
      return implode('_', $key_parts);
   }
   /**
    * Log error message
    */
   protected function logError(string $message, array $context = [], ?string $severity = null): void
   {
      if (!$this->errorHandler) {
         $this->errorHandler = JVB()->error();
      }
      // Determine severity if not provided
      if ($severity === null) {
         $severity = 'error';
      }
      // Add integration-specific context
      $context = array_merge($context, [
         'component' => 'Integration:' . $this->service_name,
         'user_id' => $this->userID,
         'service' => $this->service_name,
         'timestamp' => current_time('mysql'),
         'memory_usage' => memory_get_usage(true),
         'peak_memory' => memory_get_peak_usage(true),
         'integration_health' => [
            'is_healthy' => $this->is_healthy,
            'consecutive_errors' => $this->error_stats['consecutive_errors'],
            'total_errors' => $this->error_stats['total_errors'],
            'last_success' => $this->error_stats['last_success']
         ]
      ]);
      // Log through ErrorHandler
      $this->errorHandler->log(
         'Integration:' . $this->service_name,
         $message,
         $context,
         $severity
      );
   }
   /**
    * Categorize API errors for better tracking
    */
   protected function categorizeApiError(int $code): string
   {
      return match(true) {
         $code >= 400 && $code < 404 => 'client_error',
         $code === 404 => 'not_found',
         $code === 401 => 'authentication',
         $code === 403 => 'authorization',
         $code === 429 => 'rate_limit',
         $code >= 500 && $code < 600 => 'server_error',
         default => 'unknown'
      };
   }
   /**
    * Determine error severity based on HTTP code
    */
   protected function getErrorSeverity(int $code): string
   {
      return match(true) {
         $code === 429 => 'warning', // Rate limiting
         $code >= 400 && $code < 500 => 'error', // Client errors
         $code >= 500 => 'critical', // Server errors
         default => 'error'
      };
   }
   /**
    * Extract error details from response
    */
   protected function extractErrorDetails($decoded, string $body): array
   {
      $details = [
         'message' => 'Unknown error',
         'code' => null,
         'details' => null
      ];
      if ($decoded && isset($decoded['error'])) {
         if (is_array($decoded['error'])) {
            $details['message'] = $decoded['error']['message'] ?? json_encode($decoded['error']);
            $details['code'] = $decoded['error']['code'] ?? null;
            $details['details'] = $decoded['error']['details'] ?? null;
         } else {
            $details['message'] = $decoded['error'];
         }
      } elseif ($decoded && isset($decoded['message'])) {
         $details['message'] = $decoded['message'];
      } elseif (!empty($body)) {
         $details['message'] = $body;
      }
      return $details;
   }
   /**
    * Extract rate limit information from response
    */
   protected function extractRateLimitInfo($decoded, string $body): array
   {
      $info = [
         'retry_after' => null,
         'limit' => null,
         'remaining' => null,
         'reset' => null
      ];
      // Try to extract from decoded response
      if ($decoded) {
         $info['retry_after'] = $decoded['retry_after'] ?? $decoded['retry-after'] ?? null;
         $info['limit'] = $decoded['x-rate-limit-limit'] ?? null;
         $info['remaining'] = $decoded['x-rate-limit-remaining'] ?? null;
         $info['reset'] = $decoded['x-rate-limit-reset'] ?? null;
      }
      return $info;
   }
   /**
    * Update error statistics
    */
   protected function updateErrorStats(int $code, string $endpoint): void
   {
      $this->error_stats['total_errors']++;
      $this->error_stats['consecutive_errors']++;
      // Track error types
      $error_type = $this->categorizeApiError($code);
      if (!isset($this->error_stats['error_types'][$error_type])) {
         $this->error_stats['error_types'][$error_type] = 0;
      }
      $this->error_stats['error_types'][$error_type]++;
      // Save stats to cache
      $this->saveErrorStats();
   }
   /**
    * Reset error statistics on successful request
    */
   protected function resetErrorStats(): void
   {
      $this->error_stats['consecutive_errors'] = 0;
      $this->error_stats['last_success'] = time();
      $this->is_healthy = true;
      $this->saveErrorStats();
   }
   /**
    * Get integration health status
    */
   public function getHealthStatus(): array
   {
      return [
         'is_healthy' => $this->is_healthy,
         'consecutive_errors' => $this->error_stats['consecutive_errors'],
         'total_errors' => $this->error_stats['total_errors'],
         'last_success' => $this->error_stats['last_success'],
         'error_types' => $this->error_stats['error_types'],
         'threshold' => $this->error_threshold
      ];
   }
   /**
    * Manually reset integration health
    */
   public function resetHealth(): void
   {
      $this->error_stats = [
         'total_errors' => 0,
         'consecutive_errors' => 0,
         'last_success' => time(),
         'error_types' => []
      ];
      $this->is_healthy = true;
      $this->saveErrorStats();
      $this->logDebug('Integration health manually reset', [
         'reset_by' => get_current_user_id(),
         'reset_time' => time()
      ]);
   }
   /**
    * Check integration health based on error patterns
    */
   protected function checkIntegrationHealth(): void
   {
      // Mark unhealthy if too many consecutive errors
      if ($this->error_stats['consecutive_errors'] >= $this->error_threshold) {
         $this->is_healthy = false;
         // Log critical health issue
         $this->logError(
            "Integration marked unhealthy after {$this->error_stats['consecutive_errors']} consecutive errors",
            [
               'error_stats' => $this->error_stats,
               'threshold' => $this->error_threshold
            ],
            'critical'
         );
      }
   }
   /**
    * Load error statistics from cache
    */
   protected function loadErrorStats(): void
   {
      $cache_key = "error_stats_{$this->service_name}_{$this->userID}";
      $cached_stats = $this->cache->get($cache_key);
      if ($cached_stats) {
         $this->error_stats = array_merge($this->error_stats, $cached_stats);
         // Check if integration was marked unhealthy
         if ($this->error_stats['consecutive_errors'] >= $this->error_threshold) {
            $this->is_healthy = false;
         }
      }
   }
   /**
    * Save error statistics to cache
    */
   protected function saveErrorStats(): void
   {
      $cache_key = "error_stats_{$this->service_name}_{$this->userID}";
      $this->cache->set($cache_key, $this->error_stats, 86400); // 24 hours
   }
   protected function logDebug(string $message, array $context = []): void
   {
      error_log('[Integration: '.$this->service_name.']'.$message.': '.print_r($context, true));
   }
   /**
    * Get service name
    */
   public function getServiceName(): string
@@ -2709,23 +532,19 @@
      return $this->title;
   }
   /*********************************************************************
      RENDERING
    *********************************************************************/
// public function renderAdditionalOptions()
// {
//    //Default: nothing.
// }
   public static function title():string
   {
      return static::getInstance()->getTitle();
   }
   public static function icon():string
   {
      return static::getInstance()->getIcon();
   }
   /**
    * Render additional action buttons (optional)
    * Override in integration classes that need extra actions
    */
// public function renderAdditionalActions(): void
// {
//    // Default: no additional actions
//    // Override in extensions for service-specific actions
// }
   public static function hasExtraOptions():bool
   {
      return static::getInstance()::$hasExtraOptions;
   }
   /**
    * Get service description (optional)
@@ -2736,80 +555,6 @@
      return "Manage your {$this->getServiceName()} integration settings.";
   }
   /**
    * Get service key for consistency
    */
   public function getServiceKey(): string
   {
      return strtolower($this->service_name);
   }
   /**
    * Refresh credentials from storage and re-initialize
    */
   public function refreshCredentials(): void
   {
      $this->credentials = [];
      $this->ensureInitialized();
   }
   /**
    * Enhanced webhook handling with common patterns
    */
   public function handleWebhook(array $payload): bool
   {
      // Log incoming webhook for debugging
      $this->logDebug('Webhook received', [
         'payload_keys' => array_keys($payload),
         'timestamp' => time()
      ]);
      // Validate webhook signature/authenticity
      if (!$this->validateWebhook($payload)) {
         $this->logError('Webhook validation failed', [
            'webhook_type' => 'validation_error',
            'payload_sample' => array_slice($payload, 0, 5) // Log partial payload for debugging
         ], 'warning');
         return false;
      }
      // Check for duplicate processing (idempotency)
      if ($this->isWebhookProcessed($payload)) {
         $this->logDebug('Webhook already processed', [
            'webhook_id' => $this->extractWebhookId($payload)
         ]);
         return true; // Return true to prevent retries
      }
      try {
         // Process the webhook
         $result = $this->processWebhook($payload);
         // Mark as processed if successful
         if ($result) {
            $this->markWebhookProcessed($payload);
            $this->resetErrorStats();
         } else {
            $this->logError('Webhook processing returned false', [
               'webhook_id' => $this->extractWebhookId($payload)
            ], 'warning');
         }
         return $result;
      } catch (Exception $e) {
         $this->logError('Webhook processing failed', [
            'error' => $e->getMessage(),
            'webhook_id' => $this->extractWebhookId($payload),
            'trace' => $e->getTraceAsString()
         ], 'critical');
         // Update error stats
         $this->updateErrorStats($e->getCode() ?: 500, 'webhook');
         $this->checkIntegrationHealth();
         return false;
      }
   }
   /**
    * Validate webhook signature
@@ -2831,542 +576,14 @@
      return hash_equals($expected, $signature);
   }
   protected function validateWebhook(array $payload):bool
   {
      //Handled by child classes
      return false;
   }
   protected function processWebhook(array $payload):bool
   {
      //Handled by child classes
      return false;
   }
   /**
    * Check if webhook was already processed (prevent duplicates)
    */
   protected function isWebhookProcessed(array $payload): bool
   {
      $webhook_id = $this->extractWebhookId($payload);
      if (!$webhook_id) {
         return false;
      }
      $cache_key = "webhook_processed_{$this->service_name}_{$webhook_id}";
      return $this->cache->get($cache_key) !== null;
   }
   /**
    * Mark webhook as processed
    */
   protected function markWebhookProcessed(array $payload): void
   {
      $webhook_id = $this->extractWebhookId($payload);
      if ($webhook_id) {
         $cache_key = "webhook_processed_{$this->service_name}_{$webhook_id}";
         $this->cache->set($cache_key, time(), 86400); // Store for 24 hours
      }
   }
   /**
    * Extract unique webhook ID (override in child classes)
    */
   protected function extractWebhookId(array $payload): ?string
   {
      // Common patterns - child classes can override
      return $payload['id'] ??
         $payload['event_id'] ??
         $payload['webhook_id'] ??
         md5(serialize($payload));
   }
   /**
    * Register webhook endpoint
    */
   protected function registerWebhookEndpoint(): void
   {
      // Register REST API endpoint for webhooks
      add_action('rest_api_init', function() {
         register_rest_route('jvb/v1', '/webhooks/' . $this->service_name, [
            'methods' => 'POST',
            'callback' => [$this, 'handleWebhookRequest'],
            'permission_callback' => '__return_true', // Webhooks come from external services
         ]);
      });
   }
   /**
    * Handle webhook REST API request
    */
   public function handleWebhookRequest(WP_REST_Request $request): WP_REST_Response
   {
      $payload = $request->get_params();
      $headers = $request->get_headers();
      // Include headers in payload for signature validation
      $payload['_headers'] = $headers;
      $success = $this->handleWebhook($payload);
      return new WP_REST_Response([
         'success' => $success
      ], $success ? 200 : 400);
   }
   protected function renderWebhookUrl(): string
   {
      if (!$this->handleWebhooks || !$this->isSetUp()) {
         return '';
      }
      $webhook_url = rest_url('jvb/v1/webhooks/' . $this->service_name);
      return sprintf(
         '<div class="webhook-info">
            <h4>Webhook URL</h4>
            <code id="webhook-url-%s">%s</code>
            <p class="hint">Add this URL to your %s webhook settings</p>
        </div>',
         esc_attr($this->service_name),
         esc_html($webhook_url),
         esc_html($this->title)
      );
   }
   public function renderConnection(bool $return = false):string
   {
      if ($this->userID && !JVB()->userCanConnect($this->service_name, $this->userID)) {
         return '';
      }
      $meta = new MetaManager($this->userID, 'integrations');
      $is_connected = $this->isSetUp();
      $credentials = $this->getCredentials();
      $admin_only = $this->isOAuthService ? [
         'client_id',
         'client_secret',
      ] : [];
      ob_start();
      ?>
      <form id="<?=$this->service_name?>" class="integration <?php echo $is_connected ? 'connected' : 'disconnected'; ?>"
          data-service="<?php echo esc_attr($this->service_name); ?>">
         <div class="header row btw">
            <h3><?php echo esc_html($this->title); ?></h3>
            <div class="setup">
               <?php if ($is_connected): ?>
                  <span class="indicator connected">●</span>
                  <span class="text">Set Up</span>
               <?php else: ?>
                  <span class="indicator disconnected">●</span>
                  <span class="text">Not Set Up</span>
               <?php endif; ?>
            </div>
         </div>
         <?php if ($is_connected && array_key_exists('updated_at', $credentials) && $credentials['updated_at'] > 0): ?>
            <div class="meta">
               <small>Last updated: <?php echo human_time_diff($credentials['updated_at']) . ' ago'; ?></small>
            </div>
         <?php endif; ?>
         <?php
         if (!empty($this->instructions)) {
            ?>
            <details>
               <summary>
                  Instructions
               </summary>
               <ol>
                  <?php
                  foreach ($this->instructions as $instruction) {
                     echo '<li>'.$instruction.'</li>';
                  }
                  ?>
               </ol>
            </details>
            <?php
         }
?>
         <details class="initial-setup"<?= $is_connected?'' : ' open'?>>
            <summary>Initial Setup</summary>
            <?php
            foreach ($this->fields as $name => $config) {
               if ($is_connected && !empty($credentials[$name])) {
               if (in_array($name, $admin_only) && !current_user_can('manage_options')) {
                  continue;
               }
               ?>
               <span class="label"><?=$config['label']?>:</span>
               <code>
                  <?php
                  if (str_contains($name, 'secret')) {
                     for ($i = 1; $i<=strlen($credentials[$name]) - 8; $i++) {
                        echo '*';
                     }
                     echo substr($credentials[$name], -8);
                  } else {
                     echo $credentials[$name];
                  }
                  ?>
               </code>
               <?php
               } else {
                  $config['value'] = $credentials[$name]??'';
                  $config['autocomplete'] = 'off';
                  $config['base'] = $this->service_name.'_';
                  $meta->render('form', $name, $config);
               }
            }
            if ($this->handleWebhooks) {
               echo $this->renderWebhookUrl();
            }
            ?>
         </details>
         <?php
         if ($this->isOAuthService) {
            $this->renderConnectedOAuthStatus();
         }
         ?>
         <div class="integration-content">
            <?php
            if (!empty($this->advanced)) {
               ?>
               <details>
                  <summary>Advanced Settings</summary>
                  <?php
                  foreach ($this->advanced as $name => $config) {
                     $config['value'] = $credentials[$name]??'';
                     $config['base'] = $this->service_name.'_';
                     $config['autocomplete'] = 'off';
                     $meta->render('form', $name, $config);
                  }
                  ?>
               </details>
               <?php
            }
            if (!empty($this->defaults)) {
               ?>
               <a href="<?php echo admin_url('admin.php?page=jvb-integration-' . $this->service_name); ?>"
                  class="button">
                  More Settings
               </a>
               <?php
            }
            ?>
         </div>
         <div class="actions row btw wrap">
            <?php
            foreach ($this->buttons as $action => $label) {
               if (!$is_connected && $action !== 'save_credentials') {
                  continue;
               }
               $title = $confirm = '';
               switch ($action) {
                  case 'save_credentials':
                     $title = $label;
                     $label = jvbIcon('save');
                     break;
                  case 'clear_credentials':
                     $title = $label;
                     $label = jvbIcon('plugs');
                     $confirm = ' data-confirm="Are you sure you want to delete these credentials?"';
                     break;
                  case 'clear_cache':
                     $title = $label;
                     $label = jvbIcon('arrows-clockwise');
                     break;
               }
               $title = $title === '' ? '' : ' title ="'.$title.'"';
               ?>
               <button type="button" data-action="<?=$action?>"<?=$title?><?=$confirm?>><?=$label?></button>
               <?php
            }
            ?>
         </div>
      </form>
      <?php
      $result = ob_get_clean();
      if(!$return) {
         echo $result;
      }
      return $result;
   }
   protected function renderConnectedOAuthStatus(): void
   {
      if (!$this->isSetup()) {
         return;
      }
      $credentials = $this->getCredentials();
      $hasCredentials = $this->hasOAuthCredentials();
      $returnURL = is_admin() ? admin_url('admin.php?page=jvb-integrations') : (get_the_permalink() ?: home_url());
      ?>
      <details <?= $hasCredentials?' open':''?>>
         <summary>
            <?php if ($hasCredentials) { ?>
               Connected Account
            <?php } else { ?>
               <div class="oauth-connect">
                  <a href="<?php echo esc_url($this->getOAuthUrl($returnURL)); ?>"
                     class="button button-primary jvb-oauth-connect"
                     data-service="<?php echo esc_attr($this->service_name); ?>">
                     <?php echo jvbIcon($this->icon); ?>
                     Authorize Connection
                  </a>
               </div>
            <?php } ?>
            <div class="connection-status <?= $hasCredentials ? 'connected' : 'disconnected' ?>">
               <span class="status-indicator">●</span>
               <span><?= $hasCredentials ? 'Connected' : 'Not Connected' ?></span>
            </div>
         </summary>
         <label>OAuth Redirect URL:</label>
         <code>
            <?= $this->getRedirectUri(); ?>
         </code>
      <?php if (!empty($credentials['updated_at'])): ?>
      <div class="oauth-meta">
         <small>Token expires: <?php
            echo isset($credentials['expires_at'])
               ? human_time_diff($credentials['expires_at'])
               : 'Never';
            ?></small>
      </div>
      <?php endif;
      // Allow child classes to add service-specific connected UI
      $this->renderOAuthConnectedOptions();
      ?>
      </details>
      <?php
   }
   protected function renderOAuthConnectedOptions():void
   {
   }
   /**
    * Get last tested timestamp - extends your existing pattern
    */
   protected function getLastTestedTime(): ?int
   {
      $key = $this->userID
         ? "jvb_integration_{$this->service_name}_last_test_user_{$this->userID}"
         : "jvb_integration_{$this->service_name}_last_test";
      return get_option($key, null);
   }
   /**
    * Update last tested time
    */
   public function updateLastTestedTime(): void
   {
      $key = $this->userID
         ? "jvb_integration_{$this->service_name}_last_test_user_{$this->userID}"
         : "jvb_integration_{$this->service_name}_last_test";
      update_option($key, time());
   }
   public function handleAdminPost(): void
   {
      if (!current_user_can('manage_options')) {
         wp_die('Insufficient permissions');
      }
      $service = $this->getServiceName();
      // Verify nonce
      $nonce_field = 'jvb_integration_nonce_' . $service;
      $nonce_action = 'jvb_integration_save_' . $service;
      error_log('handleAdminPost: '.print_r($_POST, true));
      if (!isset($_POST[$nonce_field]) || !wp_verify_nonce($_POST[$nonce_field], $nonce_action)) {
         wp_die('Security check failed');
      }
      // Get the action type
      $action_type = sanitize_text_field($_POST['action_type'] ?? 'save');
      // Prepare the request in the format handleAjaxRequest expects
      $_REQUEST['action'] = $action_type;
      $_REQUEST['service'] = $service;
      // Copy all POST data to REQUEST
      foreach ($_POST as $key => $value) {
         $_REQUEST[$key] = $value;
      }
      // Set up for JSON response capture
      ob_start();
      try {
         // Call the existing AJAX handler
         $this->handleAjaxRequest();
         $response = ob_get_clean();
         // Parse the JSON response
         $result = json_decode($response, true);
         if ($result && isset($result['success'])) {
            if ($result['success']) {
               $message = $result['message'] ?? ucfirst($service) . ' settings saved successfully!';
               $this->setAdminNotice($message, 'success');
            } else {
               $message = $result['message'] ?? 'Failed to save ' . ucfirst($service) . ' settings.';
               $this->setAdminNotice($message, 'error');
            }
         } else {
            // If no proper JSON response, check if connection worked
            if ($action_type === 'test') {
               $connected = $this->testConnection();
               $message = $connected ? 'Connection successful!' : 'Connection failed. Please check your credentials.';
               $this->setAdminNotice($message, $connected ? 'success' : 'error');
            }
         }
      } catch (Exception $e) {
         ob_end_clean();
         $this->setAdminNotice('Error: ' . $e->getMessage(), 'error');
      }
      // Redirect back to integrations page
      wp_redirect(admin_url('admin.php?page=jvb-integrations'));
      exit;
   }
   /**
    * Set admin notice using transients for redirect
    */
   protected function setAdminNotice(string $message, string $type = 'info'): void
   {
      $notices = get_transient('jvb_admin_notices') ?: [];
      $notices[] = [
         'message' => $message,
         'type' => $type === 'success' ? 'updated' : 'error'
      ];
      set_transient('jvb_admin_notices', $notices, 30);
   }
   /**
    * Display admin notices from transient
    */
   public static function displayAdminNotices(): void
   {
      $notices = get_transient('jvb_admin_notices');
      if ($notices) {
         foreach ($notices as $notice) {
            ?>
            <div class="notice notice-<?php echo esc_attr($notice['type']); ?> is-dismissible">
               <p><?php echo esc_html($notice['message']); ?></p>
            </div>
            <?php
         }
         delete_transient('jvb_admin_notices');
      }
   }
   public function hasDefaults():bool
   {
      return !empty($this->defaults);
   }
   public function renderDefaults():void
   {
      $types = $this->enabledContentTypes();
      if (empty($types)) {
         return;
      }
      $meta = new MetaManager($this->userID, 'integrations');
      ?>
      <form>
         <h1><?= $this->title?> Defaults:</h1>
         <p>Find yourself constantly repeating yourself?</p>
         <p>Set defaults for different content types and <?=$this->title?>!</p>
         <?php
         foreach ($this->defaults as $name => $config) {
            $config['required'] = false;
            $config['base'] = $this->service_name.'_';
            $config['autocomplete'] = 'off';
            $meta->render('form', $name, $config);
         }
         foreach ($this->syncPostTypes as $type) {
            $config = JVB_CONTENT[$type];
            ?>
            <details>
               <summary><?= jvbIcon($config['icon']) ?><?= $config['singular']?> Defaults</summary>
               <?php
               $fields = new \JVBase\registry\providers\IntegrationFieldProvider();
               $fields = $fields->getIntegrationFields($this->service_name, $config);
               foreach($fields as $name=>$c) {
                  $c['required'] = false;
                  if ($c['type'] === 'number') {
                     $c['type'] = 'text';
                     $c['subtype'] = 'number';
                  }
                  if (array_key_exists('description', $c)) {
                     $c['hint'] = $c['description'];
                     unset($c['description']);
                  }
                  $meta->render('form', $name, $c);
               }
               ?>
            </details>
            <?php
         }
         ?>
      </form>
      <?php
   }
   public function hasContent():bool
   {
      return $this->has_content;
   }
   public function getDefaultContentType():string
   {
      return $this->defaultContent;
   }
   public function enabledContentTypes():array
   {
      if (!$this->has_content) {
         return [];
      }
      $key = BASE.$this->service_name.'_enabled_content_types';
      $enabled = get_option($key);
      if (!$enabled) {
         $enabled = [];
         foreach (array_merge(JVB_CONTENT, JVB_TAXONOMY) as $content => $config) {
            if (!array_key_exists('integrations', $config)) {
               continue;
            }
            if (!array_key_exists($this->service_name, $config['integrations'])) {
               continue;
            }
            if (!array_key_exists('content_type', $config['integrations'][$this->service_name])) {
               continue;
            }
            $type = $config['integrations'][$this->service_name]['content_type'];
            if (!in_array($type, $enabled)) {
               $enabled[] = $type;
            }
         }
         update_option($key, $enabled);
      }
      return $enabled;
      $cred = Auth::getInstance();
      $cred->updateTested($this->service_name, $this->userID);
   }
   protected function getSupportedImage(int $imgID):int
@@ -3386,145 +603,29 @@
      if ($jpegVersion !== '' && is_int($jpegVersion)) {
         return $jpegVersion;
      }
      $converted = $this->convertWebpToJpeg($imgID);
      if ($converted) {
         update_post_meta($imgID, BASE . 'jpeg_version', $converted);
         return $converted;
      $uploader = new UploadManager();
      $converted = $uploader->convertImageTo($imgID, 'jpeg', 80, false);
      if ($converted && !is_wp_error($converted)) {
         update_post_meta($imgID, BASE . 'jpeg_version', $converted['attachment_id']);
         return $converted['attachment_id'];
      }
      return $imgID;
   }
   /**
    * Convert WebP image to JPEG
    *
    * @param int $webpAttachmentId
    * @return int|false Image ID on success, false on failure.
    * Used by JVBase\registrar\helpers\AddIntegrationFields.php
    * @return array
    */
   protected function convertWebpToJpeg(int $webpAttachmentId):int|false
   public function getAdditionalFields(?string $content_type = null):array
   {
      try {
         $webpPath = get_attached_file($webpAttachmentId);
         if (!file_exists($webpPath)) {
            throw new Exception('WebP file not found');
         }
         // Generate JPEG filename
         $pathInfo = pathinfo($webpPath);
         $jpegPath = $pathInfo['dirname'] . '/' . $pathInfo['filename'] . '_jpeg.jpg';
         // Check if already exists (shouldn't happen due to meta check, but safety first)
         if (file_exists($jpegPath)) {
            // Try to find existing attachment
            global $wpdb;
            $existingId = $wpdb->get_var($wpdb->prepare(
               "SELECT ID FROM {$wpdb->posts} WHERE guid = %s",
               str_replace(ABSPATH, site_url('/'), $jpegPath)
            ));
            if ($existingId) {
               return (int)$existingId;
            }
         }
         // Perform conversion based on available libraries
         if (extension_loaded('imagick')) {
            $this->convertWebpToJpegImagick($webpPath, $jpegPath);
         } else {
            $this->convertWebpToJpegGd($webpPath, $jpegPath);
         }
         // Create attachment for the JPEG
         $attachmentData = [
            'post_mime_type' => 'image/jpeg',
            'post_title' => get_the_title($webpAttachmentId) . ' (JPEG)',
            'post_content' => '',
            'post_status' => 'inherit'
         ];
         $jpegAttachmentId = wp_insert_attachment($attachmentData, $jpegPath);
         if (is_wp_error($jpegAttachmentId)) {
            throw new Exception('Failed to create JPEG attachment: ' . $jpegAttachmentId->get_error_message());
         }
         // Generate metadata
         require_once(ABSPATH . 'wp-admin/includes/image.php');
         $attachData = wp_generate_attachment_metadata($jpegAttachmentId, $jpegPath);
         wp_update_attachment_metadata($jpegAttachmentId, $attachData);
         // Copy alt text and other meta
         $altText = get_post_meta($webpAttachmentId, '_wp_attachment_image_alt', true);
         if ($altText) {
            update_post_meta($jpegAttachmentId, '_wp_attachment_image_alt', $altText);
         }
         return $jpegAttachmentId;
      } catch (Exception $e) {
         $this->logError('Image conversion failed...', [
            'method' => 'convertWebpToJpeg'
         ]);
         return false;
      }
      return [];
   }
   /**
   * Convert WebP to JPEG using Imagick
   */
   protected function convertWebpToJpegImagick(string $source, string $destination): void
   public function getIcon():string
   {
      $image = new \Imagick($source);
      $image->setImageFormat('jpeg');
      // Set quality (using 85 for good balance of quality/size)
      $image->setImageCompressionQuality(85);
      // Remove alpha channel and set white background
      $image->setImageBackgroundColor('white');
      $image->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE);
      $image->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN);
      $image->writeImage($destination);
      $image->clear();
      return $this->icon;
   }
   /**
    * Convert WebP to JPEG using GD
    */
   protected function convertWebpToJpegGd(string $source, string $destination): void
   {
      $image = imagecreatefromwebp($source);
      if ($image === false) {
         throw new Exception('Failed to create image from WebP source');
      }
      // Get dimensions
      $width = imagesx($image);
      $height = imagesy($image);
      // Create new true color image
      $jpegImage = imagecreatetruecolor($width, $height);
      // Set white background (for transparency)
      $white = imagecolorallocate($jpegImage, 255, 255, 255);
      imagefill($jpegImage, 0, 0, $white);
      // Copy WebP image to JPEG
      imagecopy($jpegImage, $image, 0, 0, 0, 0, $width, $height);
      // Save as JPEG (quality 85)
      $result = imagejpeg($jpegImage, $destination, 85);
      // Clean up
      imagedestroy($image);
      imagedestroy($jpegImage);
      if (!$result) {
         throw new Exception('Failed to save JPEG image');
      }
   }
}