Jake Vanderwerf
10 days ago de317675a8069b747cb253ba3e2b5dc394ca36ef
inc/integrations/Integrations.php
@@ -3,17 +3,9 @@
use Exception;
use JVBase\managers\Cache;
use JVBase\managers\ErrorHandler;
use JVBase\managers\queue\executors\IntegrationExecutor;
use JVBase\managers\queue\mergers\DefaultMerger;
use JVBase\managers\queue\TypeConfig;
use JVBase\managers\UploadManager;
use JVBase\meta\Form;
use JVBase\meta\Meta;
use JVBase\registrar\helpers\AddIntegrationFields;
use JVBase\registrar\Registrar;
use WP_Error;
use WP_Post;
if (!defined('ABSPATH')) {
   exit;
@@ -32,13 +24,6 @@
{
   use _Base;
   //Flag to allow for custom settings (defaults, etc) for an integration in the dashboard
   public bool $hasExtraOptions = false;
   public bool $hasBatchCreate = false;
   public bool $hasBatchUpdate = false;
   public bool $hasBatchDelete = false;
   public bool $canCreateOnUpdate = false;
   /**
    * API Configuration
    * These properties define how the integration connects to external services
@@ -52,16 +37,11 @@
   /**
    * Credentials & State
    */
   protected string $defaultContent = 'post'; //Default integration content type, is none is set. MUST EXIST as array key in $this->>getContentTypes
   protected array $allowedContent = [];
   /**
    * Caching Configuration
    */
   protected ?string $cacheName = null;
   protected Cache $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)
@@ -69,26 +49,6 @@
      'none' => 0           // No caching for real-time data
   ];
   /**
    * 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 Registrar.php 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 Registrar.php 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 array $retryDelays = [1, 2, 5]; // Exponential backoff in seconds
   protected function __construct(?int $userID = null)
   {
@@ -98,7 +58,11 @@
      $this->getPostTypes();
      $this->getTaxonomies();
      $this->getUsers();
      if (method_exists($this, 'setContentTypes')) {
      $this->setContentTypes();
      }
      $this->registerHooks();
      if (method_exists($this, 'setQueueTypes')) {
@@ -106,192 +70,29 @@
      }
      $this->initializeRateLimiters();
      if (method_exists($this, 'initializeActions')) {
         $this->initializeActions();
      }
      if (method_exists($this, 'addOAuthActions')) {
         $this->addOAuthActions();
      }
      add_filter('jvbShouldRenderMeta', [$this, 'checkRenderField'], 10, 4);
   }
      protected function initializeRateLimiters():void
      {
         $key = $this->service_name;
         if (!is_null($this->userID)) {
            $key .= '_'.$this->userID;
         }
         if ($this->hasRequests && !isset($this->requestLimiter) && !is_null($this->oauthLimiter)) {
            $this->requestLimiter = new RateLimits($key);
         }
         if ($this->hasOAuth && !isset($this->oauthLimiter) && !is_null($this->oauthLimiter)) {
            $key .= '_oauth';
            $this->oauthLimiter = new RateLimits($key, ['s' => 1,'m' => 10, 'h' => 100]);
         }
      }
   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 (!$this->isSetUp() && str_contains($name, $this->service_name)) {
         return false;
      }
      return $shouldRender;
   }
   public function handleOAuthConnect(): array
   {
      if (!$this->hasOAuth) {
         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()) {
            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
   {
      $this->syncPostTypes = Registrar::withIntegration($this->service_name);
      $this->syncPostTypes = Registrar::withIntegration($this->service_name, 'post');
   }
   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 = [];
         foreach (Registrar::withFeature('is_content', 'term') as $type) {
            $registrar = Registrar::getInstance($type);
            if ($registrar->hasIntegration($this->service_name)) {
               $taxonomies[] = $registrar->getSlug();
      $this->syncTaxonomies = Registrar::withIntegration($this->service_name, 'term');
            }
         }
         update_option($key, $taxonomies);
      }
      $this->syncTaxonomies = $taxonomies;
   }
   protected function getRedirectUri(): string
   {
//    if (!empty($this->oauth['redirect_uri'])) {
//       return $this->oauth['redirect_uri'];
//    }
      if ($this->hasOAuth) {
         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()];
      }
   }
   /*********************************************************************
    * ABSTRACT METHODS - MUST BE IMPLEMENTED BY CHILD CLASSES
@@ -338,33 +139,20 @@
      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');
            }
         }
@@ -380,17 +168,10 @@
            $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
@@ -398,13 +179,7 @@
            $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));
      }
   }
@@ -414,9 +189,7 @@
   public function deleteCredentials():array
   {
      $success = Auth::getInstance()->deleteCredentials($this->service_name, $this->userID);
      return [
         'success'   => $success
      ];
      return $this->response(true, 'Deleted successfully');
   }
   /**
    * Validate credentials before storing
@@ -543,52 +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
    *********************************************************************/
   /*********************************************************************
    * SYNC METHODS
@@ -668,7 +395,8 @@
      // Let child classes register additional hooks if needed
      $this->registerAdditionalHooks();
      if (!empty($this->syncPostTypes) && is_null($this->userID)) {
      if (!empty($this->syncPostTypes)) {
         $this->addSavePost();
         add_action('transition_post_status', [$this, 'handlePostStatusTransition'], 10, 3);
@@ -676,64 +404,21 @@
            add_action('before_delete_post', [$this, 'handleDeletePost'], 10, 1);
         }
      }
      if (!empty($this->syncTaxonomies) && is_null($this->userID)) {
      if (!empty($this->syncTaxonomies)) {
         add_action('saved_term', [$this, 'handleSaveTerm'], 20, 5);
         if ($this->canSync['delete']) {
            add_action('pre_delete_term', [$this, 'handleDeleteTerm'], 10, 2);
         }
      }
      if (method_exists($this, 'registerQueueTypes')) {
      add_action('init', [$this, 'registerQueueTypes'], 10);
   }
      public function addSavePost():void
      {
         if (!has_action('save_post', [$this, 'handleSavePost'])) {
            add_action('save_post', [$this, 'handleSavePost'], 20, 3);
         }
      }
      public function removeSavePost():void
      {
         remove_action('save_post', [$this, 'handleSavePost'], 20, 3);
      }
      public function registerQueueTypes():void
      {
         if (empty($this->syncTaxonomies) && empty($this->syncPostTypes)) {
            return;
         }
         $queue = JVB()->queue();
         $executor = new IntegrationExecutor();
         $queue->registry()->register(self::$syncTo, new TypeConfig(
            mergeable: new DefaultMerger('items'),
            executor: $executor,
            chunkKey: 'items',
            chunkSize: 50,
            maxRetries: 3,
         ));
         if ($this->canSync['delete']) {
            $queue->registry()->register(self::$deleteFrom, new TypeConfig(
               mergeable: new DefaultMerger('external_ids'),
               executor: $executor,
               chunkKey: 'external_ids',
               chunkSize: 200,
               maxRetries: 2
            ));
         }
         $queue->registry()->register(self::$syncFrom, new TypeConfig(
            executor: $executor,
            maxRetries: 3
         ));
         $this->registerAdditionalQueueTypes($executor);
      }
         protected function registerAdditionalQueueTypes(IntegrationExecutor $executor):void
         {
            //Empty. Integration extensions can register additional operation types from here.
         }
   /**
    * Handle connection setup and credential storage
@@ -755,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 [
@@ -810,6 +495,7 @@
   /**
    * Ensure service is initialized
    * TODO: I don't think this is necessary anymore.
    */
   protected function ensureInitialized(): void
   {
@@ -819,404 +505,6 @@
      $this->initialize();
   }
   /***************************************************************
      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);
      // Store last error for debugging
      $this->lastError = [
         'code' => $code,
         'message' => $message,
         'endpoint' => $endpoint,
         'timestamp' => time(),
         'context' => $error_context
      ];
   }
   /*****************************************************************
      OAUTH
    *****************************************************************/
   /**
    * Get OAuth authorization URL
    */
   public function getOAuthUrl(?string $return_url = null): string
   {
      if (!$this->hasOAuth) {
         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);
      }
      return $this->oauth['authorize'] . '?' . http_build_query($params);
   }
   /**
    * 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;
   }
   /**
    * 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->hasOAuth || $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->hasOAuth) {
         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->hasOAuth || empty($this->credentials['refresh_token'])) {
         return false;
      }
      $request_data = [
         'client_id' => $this->credentials['client_id'],
         'client_secret' => $this->credentials['client_secret'],
         'refresh_token' => $this->credentials['refresh_token'],
         'grant_type' => 'refresh_token'
      ];
      $response = $this->makeOAuthRequest('POST', $this->oauth['token'], $request_data);
      if (is_wp_error($response)) {
         $error_message = $response->get_error_message();
         if (str_contains($error_message, 'invalid_grant')) {
            $this->logError('OAuth refresh token is invalid - user must re-authorize', [
               'error' => $error_message
            ], 'critical');
            // Mark unhealthy immediately
            $this->error_stats['consecutive_errors'] = $this->error_threshold;
            $this->is_healthy = false;
            $this->saveErrorStats();
         }
         $this->logError('Failed to refresh OAuth token for '.$this->service_name, [
            'error' => $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: Some services return 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->hasOAuth || 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 Auth::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
@@ -1225,268 +513,6 @@
   {
      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 = Meta::forPost($postID);
      $service_data = [];
      foreach ($mapping as $wp_field => $service_field) {
         $value = $meta_manager->get($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
   {
      if (!is_null($this->userID)) {
         return;
      }
      error_log('=== ['.$this->service_name.']::handleSavePost called');
      if (!$postID || $postID === 0) {
         return;
      }
      $postType = jvbNoBase($post->post_type);
      if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
      if (wp_is_post_revision($postID)) return;
      if (empty($this->syncPostTypes) || !in_array(jvbNoBase($postType), $this->syncPostTypes)) {
         error_log('Not handling save for '.$this->service_name.' because there are no syncPostTypes: '.print_r($this->syncPostTypes, true));
         return;
      }
      $registrar = Registrar::getInstance($postType);
      if (!$registrar){
         return;
      }
      $settings = $registrar->hasIntegration($this->service_name)??null;
      if (!$settings) {
         error_log('Not handling save for '.$this->service_name.' because of no registrar settings');
         return;
      }
      $settings = $registrar->getIntegrationConfig($this->service_name);
      if (!$settings){
         error_log('Not handling save for '.$this->service_name.' because of no integration config '.print_r($settings, true));
         return;
      }
      $fields = $this->getSyncFields($postID, 'post', ['schedule_'.$this->service_name]);
      if (!$fields['share_to_'.$this->service_name]) {
         error_log('Not handling save for '.$this->service_name.' because of no share_to_'.$this->service_name.' '.print_r($fields, true));
         return;
      }
      $isShared = isset($fields["_{$this->service_name}_item_id"]);
      if ($update && $isShared && !$fields['_keep_synced_'.$this->service_name]) {
         error_log('Not handling save for '.$this->service_name.' because it is already shared, and not set to keep synced. ');
         return;
      }
      if ($post->post_status !== 'publish' && !$isShared) {
         error_log('Not handling save for '.$this->service_name.' because post status is not publish, and it is not already shared.');
         return;
      }
      error_log('==== Sending to integration\'s handleTheSavePost '.$this->service_name.' ====');
      $this->removeSavePost();
      $this->handleTheSavePost($postID, $post, $update, $settings);
      $this->addSavePost();
   }
   protected function getSyncFields(int $postID, string $type, array $additional = []):array
   {
      $meta = new Meta($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;
      }
      $registrar = Registrar::getInstance($noBase);
      if (!$registrar->hasFeature('is_content')) {
         return;
      }
      $settings = $registrar->getIntegrationConfig($this->service_name);
      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) {
   }
   /*******************************************************************
@@ -1494,183 +520,6 @@
    *******************************************************************/
   /**
    * Get API URL for endpoint
    */
   protected function getApiUrl(string $endpoint, ?string $baseKey = null): string|false
   {
      if ($this->hasOAuth && 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;
   }
   /**
    * 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();
   }
   /**
    * Get service name
    */
   public function getServiceName(): string
@@ -1697,24 +546,6 @@
      return static::getInstance()::$hasExtraOptions;
   }
   /*********************************************************************
      RENDERING
    *********************************************************************/
// public function renderAdditionalOptions()
// {
//    //Default: nothing.
// }
   /**
    * 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
// }
   /**
    * Get service description (optional)
    * Override in integration classes for custom descriptions
@@ -1746,236 +577,6 @@
   }
   public function renderConnection(bool $return = false):string
   {
      if ($this->userID && !JVB()->userCanConnect($this->service_name, $this->userID)) {
         return '';
      }
      $meta = Meta::forOptions($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 x-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.'_';
                  echo Form::render($name, '', $config);
               }
            }
            if ($this->hasWebhooks) {
               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';
                     Form::render($name,null, $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 x-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('floppy-disk');
                     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
   {
   }
   /**
    * Update last tested time
    */
@@ -1985,180 +586,6 @@
      $cred->updateTested($this->service_name, $this->userID);
   }
   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;
      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 = Meta::forOptions($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';
            echo Form::render($name, null, $config);
         }
         foreach ($this->syncPostTypes as $type) {
            $registrar = Registrar::getInstance($type);
            $icon = $registrar->getIcon();
            $icon = $icon === '' ? jvbDefaultIcon() : $icon;
            ?>
            <details>
               <summary><?= jvbIcon($icon) ?><?= $registrar->getSingular()?> Defaults</summary>
               <?php
               $fields = new AddIntegrationFields($this->service_name);
               $fields = $fields->getIntegrationFields();
               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']);
                  }
                  echo Form::render($name, null, $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 [];
      }
      return array_filter(array_map(function($registrar) {
         $registrar = Registrar::getInstance($registrar);
         return $registrar->getIntegration($this->service_name)->getContentType();
      }, Registrar::withIntegration($this->service_name)));
   }
   protected function getSupportedImage(int $imgID):int
   {
      //If this integration supports webp, we can just send the original image id
@@ -2187,11 +614,6 @@
   }
   public function getAllowedContent():array
   {
      return $this->allowedContent;
   }
   /**
    * Used by JVBase\registrar\helpers\AddIntegrationFields.php
    * @return array
@@ -2206,18 +628,4 @@
      return $this->icon;
   }
   public function canBatchUpdate():bool
   {
      return $this->hasBatchUpdate;
   }
   public function canBatchCreate():bool
   {
      return $this->hasBatchCreate;
   }
   public function canBatchDelete():bool
   {
      return $this->hasBatchDelete;
   }
}