Jake Vanderwerf
2026-08-07 a8ff85042ffbfe94b678fd818c02deb613bfd116
inc/integrations/services/GoogleMyBusiness.php
@@ -1,7 +1,15 @@
<?php
namespace JVBase\integrations\services;
use Exception;
use JVBase\integrations\Admin;
use JVBase\integrations\Instance;
use JVBase\integrations\Integrations;
use JVBase\meta\Meta;
use JVBase\integrations\OAuth;
use JVBase\integrations\OAuthCredentials;
use JVBase\integrations\OAuthURLs;
use JVBase\integrations\Requests;
use JVBase\integrations\UserConnection;
if (!defined('ABSPATH')) {
   exit;
@@ -9,2967 +17,237 @@
class GoogleMyBusiness extends Integrations
{
   protected array $allowedContent = [
      'menu_item',
      'post',
      'event',
   ];
   private ?string $access_token = null;
   use Admin, Instance, OAuth, Requests, UserConnection;
   protected string $readMask = 'name,title,storefrontAddress,metadata,openInfo,storeCode,categories,phoneNumbers,labels,specialHours';
   private ?string $location = null;
   private ?string $refresh_token = null;
   private ?string $client_id = null;
   private ?string $client_secret = null;
   private ?string $account_id = null;
   protected static array $instances = [];
   public static function getInstance(?int $userID = null):self
   {
      $key = is_null($userID) ? 'base' : $userID;
      if (!array_key_exists($key, self::$instances)) {
         self::$instances[$key] = new self($userID);
      }
      return self::$instances[$key];
   }
   protected function __construct(?int $userID = null)
   protected function __construct()
   {
      $this->service_name = 'gmb';
      $this->title = 'Google My Business';
      $this->icon = 'storefront';
      $this->cacheName = ($userID)? 'gmb_'.$userID : 'gmb';
      $this->canSync = [
         'initial'   => true,
         'update' => true,
         'delete' => true,
      ];
      $this->apiBase = [
         'base' => 'https://mybusinessbusinessinformation.googleapis.com',
         'accounts' => 'https://mybusinessaccountmanagement.googleapis.com',
         'posts' => 'https://mybusiness.googleapis.com',
         'performance'=> 'https://businessprofileperformance.googleapis.com',
         'v4' => 'https://mybusiness.googleapis.com/v4',
      ];
      $this->supportsWebp = false;
      $this->apiEndpoints = [
         '/accounts/[^/]+/locations/[^/]+/reviews',
         '/accounts/[^/]+/locations/[^/]+/foodMenus',
         '/v4/accounts/[^/]+/locations/[^/]+/media',
         '/v4/accounts/[^/]+/locations/[^/]+/localPosts',
         '/v4/accounts/[^/]+/locations/[^/]+/localPosts/[^/]+',
         '/accounts/[^/]+/locations/[^/]+',
         '/v1/accounts/[^/]+/locations', // Matches /v1/accounts/{accountId}/locations
         '/v1/accounts',
         // Add pattern matching for dynamic location endpoints
         '/v1/locations/[^/]+',  // Matches /v1/locations/{locationId}
      ];
      $this->defineFields();
      $this->defineInstructions();
      $this->defineOAuth();
      $this->defineActions();
      $this->isOAuthService = true;
      $this->oauth = [
         'authorize' => 'https://accounts.google.com/o/oauth2/v2/auth',
         'token' => 'https://oauth2.googleapis.com/token',
         'revoke' => 'https://oauth2.googleapis.com/revoke',
         'scopes' => [
      parent::__construct();
   }
      protected function defineFields():void
      {
         $client_id = $this->addField(
            'client_id',
            'OAuth Client ID',
            'text',
            'manage_options'
         );
            $client_id->setRequired();
            $client_id->setHint('From your Google Cloud Console OAuth credentials.');
         $secret = $this->addField(
            'client_secret',
            'OAuth Client Secret',
            'text',
            'manage_options',
         );
            $secret->setSubType('password');
            $secret->setRequired();
         if ($this->getAccessToken()) {
            $accs = $this->addField('accounts', 'Accounts', 'select', [$this, 'testUserCanConnect']);
               $accs->setHidden();
               $accs->setOptions($this->getAccountOptions());
            $acc = $this->addField('account_id', 'Set Account', 'select', [$this, 'testUserCanConnect']);
               $acc->setOptions($this->loadCredentials()['accounts']??[]);
               $acc->setHint('Select the account to connect to.');
            if ($this->getAccountID()) {
               $locs = $this->addField('locations', 'Locations', 'select', [$this, 'testUserCanConnect']);
               $locs->setHidden();
               $locs->setOptions($this->getLocationOptions());
               $loc = $this->addField('location_id', 'Set Location', 'select', [$this, 'testUserCanConnect']);
               $loc->setOptions($this->loadCredentials()['locations']??[]);
               $loc->setHint('Select the location to connect to.');
            }
         }
      }
         public function testUserCanConnect():bool
         {
            $user = get_current_user_id();
            return $this->userCanConnect($user);
         }
      protected function defineInstructions():void
      {
         $this->instructions = [
            'Go to <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a>',
            'Create OAuth 2.0 Client ID credentials',
            'Enable Google My Business API',
            'Add redirect URI: <code>'.esc_html($this->getRedirectUri()).'</code>',
            'Use the "Authorize" button below to complete OAuth flow'
         ];
      }
      protected function defineOAuth():void
      {
         $this->oauth = new OAuthURLs(
            'https://accounts.google.com/o/oauth2/v2/auth',
            'https://oauth2.googleapis.com/token',
            'https://oauth2.googleapis.com/revoke'
         );
         $this->oathScopes = [
            'https://www.googleapis.com/auth/business.manage',
            'https://www.googleapis.com/auth/userinfo.email',
            'https://www.googleapis.com/auth/userinfo.profile',
            'https://www.googleapis.com/auth/plus.business.manage'
         ]
      ];
      $this->supportsWebp = false;
      $this->rate_limits = [
         'per_second'   => 15,       // Allow 15 requests in burst window
         'per_minute'   => 60,      // 1-minute burst window
         'per_hour'     => 500      // 500 requests per hour (well below Google's limits)
      ];
      $this->fields = [
         'client_id' => [
            'type'   => 'text',
            'label'  => 'OAuth Client ID',
            'placeholder'=> 'Enter Google OAuth Client ID',
            'hint'   => 'From your Google Cloud Console OAuth credentials.',
            'required'  => true,
         ],
         'client_secret'   => [
            'type'   => 'text',
            'subtype' => 'password',
            'label'  => 'OAuth Client Secret',
            'required'  => true,
         ],
//       'access_token' => [
//          'type'   => 'text',
//          'subtype'   => 'password',
//          'label'  => 'Access Token',
//          'hint'   => 'Generated automagically after OAuth authorization.'
//       ],
//       'refresh_token'   => [
//          'type'   => 'text',
//          'subtype'   => 'password',
//          'label'  => 'Refresh Token',
//          'hint'   => 'Generated automagically after OAuth authorization.'
//       ]
      ];
      $this->advanced = [
      ];
      $this->instructions = [
         'Go to <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a>',
         'Create OAuth 2.0 Client ID credentials',
         'Enable Google My Business API',
         'Add redirect URI: <code>'.esc_html($this->getRedirectUri()).'</code>',
         'Use the "Authorize" button below to complete OAuth flow'
      ];
      parent::__construct();
      $this->actions = array_merge(
         $this->actions,
         [
            'update_location' => 'handleUpdateLocation',
            'select_account'  => 'handleSelectAccount',
            'select_location' => 'handleSelectLocation',
            'check_oauth_status'=> 'checkOAuthStatus'
         ]
      );
      $this->buttons = array_merge(
         $this->buttons,
         [
            'check_oauth_status' => 'Check OAuth Status'
         ]
      );
      if (JVB_TESTING) {
         $this->cache->flush();
         ];
      }
   }
      protected function defineActions():void
      {
         $this->addAction(
            'Update Location',
            [$this, 'handleUpdateLocation'],
            'update_location',
         );
      }
         public function handleUpdateLocation(array $data):array
         {
            if (empty($data['location_id']??'')) {
               return $this->response(false, 'No location selected');
            }
            $credentials = [
               'location_id'  => sanitize_text_field($data['location_id'])
            ];
            return $this->saveCredentials($credentials, false);
         }
   protected function initialize(): void
   {
      if (empty($this->credentials)) {
         $this->loadCredentials();
      }
      $this->access_token = (array_key_exists('access_token', $this->credentials)) ? $this->credentials['access_token'] : null;
      $this->refresh_token = (array_key_exists('refresh_token', $this->credentials)) ? $this->credentials['refresh_token'] : null;
      $this->client_id = (array_key_exists('client_id', $this->credentials)) ? $this->credentials['client_id'] : null;
      $this->client_secret = (array_key_exists('client_secret', $this->credentials)) ? $this->credentials['client_secret'] : null;
      $this->location = (array_key_exists('location', $this->credentials)) ? $this->credentials['location'] : null;
      $this->account_id = (array_key_exists('account', $this->credentials)) ? $this->credentials['account'] : null;
      if ($this->account_id) {
         $this->apiEndpoints[] = "/v1/{$this->account_id}/locations";
      }
      if ($this->location) {
         $locationName = $this->getSelectedLocationResourceName();
         $this->apiEndpoints[] = "/v4/{$locationName}/localPosts";
      }
   }
   protected function performConnectionTest(): bool
   {
      try {
         $accounts = $this->getAccounts(true);
         return !empty($accounts);
      } catch (\Exception $e) {
         return false;
      }
   }
   /**
    * Check if response contains an error - Google-specific
    */
   protected function isErrorResponse(array $response): bool
   {
      // Google APIs return errors in this format:
      // {"error": {"code": 401, "message": "...", "status": "UNAUTHENTICATED"}}
      return isset($response['error']) && isset($response['error']['code']);
      // TODO: Implement initialize() method.
   }
   protected function getRequestHeaders(): array
   {
      return [
         'Authorization' => 'Bearer ' . $this->access_token,
         'Content-Type' => 'application/json',
         'Accept' => 'application/json'
      ];
      // TODO: Implement getRequestHeaders() method.
   }
   protected function addOAuthParams(array $params): array
   protected function handleResponse(\WP_Error|array $response, string $method, string $endpoint): array
   {
      $params['access_type'] = 'offline';
      $params['prompt'] = 'consent';
      return $params;
      if (is_wp_error($response) && $response->get_error_message() === 'rate_limit_exceeded') {
         return $this->response(false, 'Rate limit exceeded');
      }
      return match($endpoint) {
         'https://mybusinessaccountmanagement.googleapis.com/v1/accounts' => $this->handleGetAccountsResponse($response, $method),
         sprintf('https://mybusinessbusinessinformation.googleapis.com/v1/%s/locations', $this->getAccountID()??'') => $this->handleGetLocationsResponse($response, $method),
         default => $this->response(false, 'Unhandled response for '.$endpoint.' endpoint, with method: '.$method)
      };
   }
   protected function addCredentialData(array $credentials, array $tokens): array
   protected function refreshAccessToken(OAuthCredentials $credentials): array
   {
      // Preserve GMB-specific settings
      $existing = $this->credentials;
      $credentials['location'] = $existing['location'] ?? null;
      $credentials['account'] = $existing['account'] ?? null;
      return $credentials;
      // TODO: Implement refreshAccessToken() method.
   }
   public function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings):void
   /*********************************************************
    * UTILITY
   *********************************************************/
   protected function getAccountID():string|false
   {
      if (!$this->hasOAuthCredentials()) {
         error_log('OAuth Not set up for '.$this->service_name);
         return;
      }
      $this->queueOperation(self::$syncTo, [
         'items'     => [$postID],
         'user'      => user_can($post->post_author, 'manage_options') ? null : $post->post_author
      ], [
         'delay'  => 60
      ]);
      Meta::forPost($postID)->set('_'.$this->service_name.'_sync_status', 'queued');
      return $this->loadCredentials()['account_id']??false;
   }
   public function processOperation(\WP_Error|array $result, object $operation, array $data):\WP_Error|array
   protected function getAccountOptions(bool $force = false):array
   {
      try {
         $gmb = (array_key_exists('user', $data) && $data['user'] !== 0) ? $this->getInstance((int)$data['user']) : $this;
         $gmb->ensureInitialized();
         switch ($operation->type) {
            case 'gmb_create_menu_item':
            case 'gmb_update_menu_item':
            case 'gmb_menu_update':
            case 'gmb_menu_update_' . $this->userID:
               return $gmb->processMenuUpdate($operation, $data);
            case 'gmb_create_event':
            case 'gmb_update_event':
               return $gmb->syncEventPost($operation, $data);
            case 'gmb_create_offer':
            case 'gmb_update_offer':
               return $gmb->syncOfferPost($operation, $data);
            case 'gmb_create_post':
            case 'gmb_update_post':
               return $gmb->syncStandardPost($operation, $data);
            case 'gmb_specialHours':
               return $gmb->setSpecialHours($data['date'], $data['data']);
            case 'gmb_business_hours':
               return $gmb->updateBusinessHours($data);
            case 'gmb_business_info':
               return $gmb->updateBusinessInfo($data);
            case 'gmb_update_attributes':
               return $gmb->updateAttributes($data);
            default:
               return $result;
         }
      } catch (\Exception $e) {
         error_log('[GMB] Operation processing error: ' . $e->getMessage());
         return [
            'success' => false,
            'result' => $e->getMessage()
         ];
      $stored = $this->loadCredentials()['accounts']??[];
      if ($force || empty($stored)) {
         $stored = $this->getAccounts();
      }
      return $stored;
   }
   protected function syncEventPost(object $operation, array $data):\WP_Error|array
   protected function getLocationID():string|false
   {
      $location = $this->getSelectedLocationResourceName();
      if (!$location) {
         return [
            'success' => false,
            'result'  => 'No location Name set.'
         ];
      }
      $postID = $data['post_id'];
      $meta = Meta::forPost($postID);
      $fields = [
         'start_date',
         'end_date',
         'start_time',
         'end_time',
         'post_excerpt',
         'post_title',
         'post_thumbnail',
         "_{$this->service_name}_item_id"
      ];
      $data = [
         'summary'      => $fields['post_excerpt'],
         'topic_type'   => 'EVENT',
         'event'        => [
            'title'  => $fields['post_title'],
            'start_date'   => $this->formatDate($fields['start_date']),
            'end_date'     => $this->formatDate(($fields['end_date'] === '') ? $fields['start_date'] : $fields['end_date']),
            'start_time'   => $this->formatTime($fields['start_time']),
            'end_time'     => $this->formatTime($fields['end_time']),
         ]
      ];
      if ($fields['post_thumbnail'] !== '') {
         $url = wp_get_attachment_url($fields['post_thumbnail']);
         if ($url) {
            $data['media'] = [
               ['mediaFormat' => 'PHOTO', 'sourceUrl' => $url]
            ];
         }
      }
      if ($fields["_{$this->service_name}_item_id"] !== '') {
         $result =  $this->updatePost($fields["_{$this->service_name}_item_id"], $data);
      } else {
         $result = $this->createPost($data);
         $meta->set("_{$this->service_name}_item_id", $result['name']);
      }
      return [
         'success'   => !empty($result),
         'result' =>  $result
      ];
      return $this->loadCredentials()['location_id']??false;
   }
   protected function syncOfferPost(object $operation, array $data):\WP_Error|array
   protected function getLocationOptions(bool $force = false):array
   {
      $location = $this->getSelectedLocationResourceName();
      if (!$location) {
         return [
            'success' => false,
            'result'  => 'No location Name set.'
         ];
      $stored = $this->loadCredentials()['locations']??[];
      if ($force || empty($stored)) {
         $stored = $this->getLocations();
      }
      $postID = $data['post_id'];
      $meta = Meta::forPost($postID);
      $fields = [
         'post_excerpt',
         'post_title',
         'coupon_code',
         'termsConditions',
         'post_thumbnail',
         "_{$this->service_name}_item_id"
      ];
      $data = [
         'summary'      => $fields['post_excerpt'],
         'topic_type'   => 'OFFER',
         'offer'        => [
            'couponCode'   => ($fields['coupon_code'] === '') ? $fields['post_title'] : $fields['coupon_code'],
            'redeemOnlineUrl'=> get_the_permalink($postID),
//          'termsConditions'=> get_bloginfo('terms_condition') TODO: setup terms and Conditions
         ]
      ];
      if ($fields['post_thumbnail'] !== '') {
         $url = wp_get_attachment_url($fields['post_thumbnail']);
         if ($url) {
            $data['media'] = [
               ['mediaFormat' => 'PHOTO', 'sourceUrl' => $url]
            ];
         }
      }
      if ($fields["_{$this->service_name}_item_id"] !== '') {
         $result =  $this->updatePost($fields["_{$this->service_name}_item_id"], $data);
      } else {
         $result = $this->createPost($data);
         $meta->set("_{$this->service_name}_item_id", $result['name']);
      }
      return [
         'success'   => !empty($result),
         'result' =>  $result
      ];
      return $stored;
   }
   protected function syncStandardPost(object $operation, array $data):\WP_Error|array
   protected function getLocations():array|false
   {
      $location = $this->getSelectedLocationResourceName();
      if (!$location) {
         return [
            'success' => false,
            'result'  => 'No location Name set.'
         ];
      }
      $postID = $data['post_id'];
      $meta = Meta::forPost($postID);
      $fields = [
         'post_excerpt',
         'post_title',
         'post_thumbnail',
         "_{$this->service_name}_item_id"
      ];
      $data = [
         'summary'      => $fields['post_excerpt'],
         'topic_type'   => 'STANDARD',
         'call_to_action'        => [
            'type'   => 'learn_more',
            'url'=> get_the_permalink($postID),
         ]
      ];
      if ($fields['post_thumbnail'] !== '') {
         $url = wp_get_attachment_url($fields['post_thumbnail']);
         if ($url) {
            $data['media'] = [
               ['mediaFormat' => 'PHOTO', 'sourceUrl' => $url]
            ];
         }
      }
      if ($fields["_{$this->service_name}_item_id"] !== '') {
         $result =  $this->updatePost($fields["_{$this->service_name}_item_id"], $data);
      } else {
         $result = $this->createPost($data);
         $meta->set("_{$this->service_name}_item_id", $result['name']);
      }
      return [
         'success'   => !empty($result),
         'result' =>  $result
      ];
   }
   protected function processMenuUpdate($operation, $data) {
      try {
         $postID = $data['post_id'];
         if (!$postID) {
            return [
               'success'   => false,
               'result' => 'No post ID set'
            ];
         }
         $post = get_post($postID);
         $postType = jvbNoBase($post->post_type);
         $args = [
            'post_type' => jvbCheckBase($postType),
            'post_status' => 'publish',
            'posts_per_page' => -1,
            'meta_query' => [
               [
                  'key' => BASE.'_keep_synced_' . $this->service_name,
                  'value' => '1',
                  'compare' => '='
               ]
            ]
         ];
         $menu_items = get_posts($args);
         if (empty($menu_items)) {
            return [
               'success' => true,
               'result' => 'No menu items to sync'
            ];
         }
         $gmb = self::getInstance($data['userID']??null);
         // Build the complete menu structure
         $menu_data = $gmb->collectMenu($menu_items);
         // Send to Google My Business API
         $result = $this->updateFoodMenus($menu_data);
         return [
            'success' => !is_null($result),
            'result' => (is_array($result)) ? array_merge($result, ['menu'=>$menu_data]) : ['menu'=>$menu_data],
         ];
      } catch (\Exception $e) {
         error_log('GMB Menu Update Error: ' . $e->getMessage());
         return [
            'success'   => false,
            'result' => $e->getMessage()
         ];
      }
   }
   /***************************************************************
      API
    ***************************************************************/
   private function getSelectedLocationResourceName(): ?string
   {
      if (empty($this->credentials['location'])) {
         return null;
      }
      // If already a full resource name, return as-is
      if (str_starts_with($this->credentials['location'], 'locations/')) {
         return $this->credentials['location'];
      }
      // Otherwise, it's a storeCode - need to find the full resource name
      try {
         $accounts = $this->getAccounts();
         foreach ($accounts as $account) {
            $locations = $this->getLocations($account['name']);
            foreach ($locations as $location) {
               if ($location['storeCode'] === $this->credentials['location']) {
                  // Auto-migrate: update stored location to use full resource name
                  $this->setSelectedLocation($location['name']);
                  return $location['name']; // This is the full resource name
               }
            }
         }
      } catch (\Exception $e) {
         error_log('[GMB] Error finding location resource name: ' . $e->getMessage());
      }
      return null;
   }
   /**
    * Update business information
    */
   /**
    * @param array $updates
    * @return array As expected by OperationQueue.php processOperation
    */
   public function updateBusinessInfo(array $updates): array
   {
      /**
      SEE ALSO: https://developers.google.com/my-business/content/attributes
      MENU EXAMPLE
      You can add structured menu data, like a food menu, to a location with the use of the PriceList object.
      The following JSON request shows how to publish a breakfast menu to a location. The response contains an instance of the updated Location object.
      HTTP
      PATCH
      https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}?updateMask=priceLists
      {
      "priceLists": [
      {
      "priceListId": "Breakfast",
      "labels": [
      {
      "displayName": "Breakfast",
      "description": "Tasty Google Breakfast",
      "languageCode": "en"
      }
      ],
      "sourceUrl": "http://www.google.com/todays_menu",
      "sections": [
      {
      "sectionId": "entree_menu",
      "sectionType":"FOOD",
      "labels": [
      {
      "displayName": "Entrées",
      "description": "Breakfast Entrées",
      "languageCode": "en"
      }
      ],
      "items": [
      {
      "itemId": "scramble",
      "labels": [
      {
      "displayName": "Big Scramble",
      "description": "A delicious scramble filled with Potatoes, Eggs,
      Bell Peppers, and Sausage",
      "languageCode": "en"
      }
      ],
      "price": {
      "currencyCode": "USD",
      "units": "12",
      "nanos": "200000000"
      }
      },
      {
      "itemId": "steak_omelette",
      "labels": [
      {
      "displayName": "Steak Omelette",
      "description": "Three egg omelette with grilled prime rib,
      fire-roasted bell peppers and onions, saut\u00e9ed mushrooms
      and melted Swiss cheese",
      "languageCode": "en"
      }
      ],
      "price": {
      "currencyCode": "USD",
      "units": "15",
      "nanos": "750000000"
      }
      }
      ]
      }
      ]
      }
      ]
      }
      SERVICE DATA
      If your business offers different service options, you can add structured services data to a location with the use of the PriceList object.
      The following JSON request shows how to publish a service offering to a location. The response contains an instance of the updated Location object.
      HTTP
      PATCH
      https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}?updateMask=priceLists
      {
      "priceLists": [
      {
      "priceListId": "Oil Change",
      "labels": [
      {
      "displayName": "Oil Change",
      "description": "Caseys Qwik Oil Change",
      "languageCode": "en"
      }
      ],
      "sourceUrl": "http://www.google.com/todays_services",
      "sections": [
      {
      "sectionId": "oil_services",
      "sectionType":”SERVICES”,
      "labels": [
      {
      "displayName": "Services",
      "description": "Oil Changes",
      "languageCode": "en"
      }
      ],
      "items": [
      {
      "itemId": "20-minute-oil-change",
      "labels": [
      {
      "displayName": "20 Minute Oil Change",
      "description": "Quick oil change and filter service.",
      "languageCode": "en"
      }
      ],
      "price": {
      "currencyCode": "USD",
      "units": "30",
      "nanos": "200000000"
      }
      },
      {
      "itemId": "full_service_oil_change",
      "labels": [
      {
      "displayName": "Full Service Oil Change",
      "description": "Quick oil change, filter service, and brake inspection.",
      "languageCode": "en"
      }
      ],
      "price": {
      "currencyCode": "USD",
      "units": "45",
      "nanos": "750000000"
      }
      }
      ]
      }
      ]
      }
      ]
      }
       **/
      try {
         $allowed_fields = [
            'title',           // Business name
            'phoneNumbers',    // Primary phone
            'websiteUri',      // Website URL
            'categories',      // Business categories
            'storefrontAddress', // Physical address
            'serviceArea',     // Service area for service businesses
            'profile',         // Business description
            'openInfo',        // Opening date info
            'regularHours',    // Business hours
            'specialHours',    // Special hours
         ];
         // Filter to only allowed fields
         $patch_data = array_intersect_key($updates,$allowed_fields);
         $location_name = $this->getSelectedLocationResourceName();
         if (empty($patch_data)) {
            throw new \Exception('No valid fields to update');
         }
         // Create updateMask from the fields being updated
         $update_mask = implode(',', array_keys($patch_data));
         $response = $this->makeRequest(
            'PATCH',
            "/v1/{$location_name}?updateMask={$update_mask}",
            $patch_data,
            'base'
         );
         return [
            'success'   => !empty($response),
            'result' => $response
         ];
      } catch (\Exception $e) {
         $this->logError($e->getMessage(), [
            'method' => 'updateBusinessInfo'
         ]);
         return [
            'success'   => false,
            'result' => $e->getMessage(),
         ];
      }
   }
   /**
    * Update business attributes (amenities, services, etc.)
    */
   /**
    * @param string $location_name
    * @param array $attributes
    * @return array As expected by OperationQueue.php processOperation
    */
   public function updateAttributes(array $attributes): array
   {
      try {
         // Attributes like: wheelchair_accessible, wifi, parking, etc.
         //TODO: sanitize data
         $attributes_data = [
            'attributes' => $attributes
         ];
         $location_name = $this->getSelectedLocationResourceName();
         $response = $this->makeRequest(
            'PATCH',
            "/v1/accounts/{$location_name}/attributes",
            $attributes_data,
            'base'
         );
         return [
            'success'   => !empty($response),
            'result' => $response
         ];
      } catch (\Exception $e) {
         $this->logError($e->getMessage(), [
            'method' => 'updateAttributes'
         ]);
         return [
            'success'   => false,
            'result' => $e->getMessage()
         ];
      }
   }
   /*****************
    * POSTS
    ****************/
   /**
    * Create a new GMB post
    *
    * @param string $location_name The location resource name
    * @param array $post_data Post content including summary, callToAction, media, etc.
    * @return array|null Created post data or null on failure
    */
   /**
   FROM THE DOCS:
   EVENT POST
   Notify your customers about the next event at your business with a Post. Your Post for an event includes start and end dates and times, which display prominently on the Post.
   To make a Post to an account associated with a user, use the accounts.locations.localPosts API.
   To create a Post for an authenticated user, use the following:
   $ POST
   https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts
   {
   "languageCode": "en-US",
   "summary": "Come in for our spooky Halloween event!",
   "event": {
   "title": "Halloween Spook-tacular!",
   "schedule": {
   "startDate": {
   "year": 2017,
   "month": 10,
   "day": 31,
   },
   "startTime": {
   "hours": 9,
   "minutes": 0,
   "seconds": 0,
   "nanos": 0,
   },
   "endDate": {
   "year": 2017,
   "month": 10,
   "day": 31,
   },
   "endTime": {
   "hours": 17,
   "minutes": 0,
   "seconds": 0,
   "nanos": 0,
   },
   }
   },
   "media": [
   {
   "mediaFormat": "PHOTO",
   "sourceUrl": "https://www.google.com/real-image.jpg",
   }
   ],
   "topicType": "EVENT"
   }
   CALL TO ACTION POST
   Posts with a call to action include a button. The text on the call to action button is determined by the actionType field of the Post. A link to a user-provided URL is added to the button.
   To create a Post with a call to action button, use the following:
   $ POST
   https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts
   {
   "languageCode": "en-US",
   "summary": "Order your Thanksgiving turkeys now!!",
   "callToAction": {
   "actionType": "ORDER",
   "url": "http://google.com/order_turkeys_here",
   },
   "media": [
   {
   "mediaFormat": "PHOTO",
   "sourceUrl": "https://www.google.com/real-turkey-photo.jpg",
   }
   ],
   "topicType": "OFFER"
   }
   Action types
   The call to action Posts can have different action types that determine the type of call to action Post.
   The following are the supported call to action types:
   Action types
   BOOK  Creates a Post that prompts a user to book an appointment, table, or something similar.
   ORDER Creates a Post that prompts a user to order something.
   SHOP  Creates a Post that prompts a user to browse a product catalog.
   LEARN_MORE  Creates a Post that prompts a user to see additional details on a website.
   SIGN_UP  Creates a Post that prompts a user to register, sign up, or join something.
   CALL  Creates a Post that prompts a user to call a business.
   OFFER POST
   To create an Offer Post, use the following:
   HTTP
   $ POST
   https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts
   {
   "languageCode": "en-US",
   "summary": "Buy one Google jetpack, get a second one free!!",
   "offer": {
   "couponCode": “BOGO-JET-CODE”,
   "redeemOnlineUrl": “https://www.google.com/redeem”,
   "termsConditions": “Offer only valid if you can prove you are a time traveler”
   },
   "media": [
   {
   "mediaFormat": "PHOTO",
   "sourceUrl": "https://www.google.com/real-jetpack-photo.jpg",
   }
   ],
   "topicType": "OFFER"
   }
   EDIT POST
   Once a post is created, you can edit it with a PATCH request.
   To edit a Post, use the following:
   HTTP
   $ PATCH
   https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts/{localPostId}?updateMask=summary
   {
   "summary": "Order your Christmas turkeys now!!"
   }
   DELETE POSTS
   After a Post is created, you can delete it with a DELETE request.
   To delete a Post, use the following:
   HTTP
   $ DELETE
   https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts/{localPostId}
    **/
   public function createPost(array $post_data, ?string $location_name = null): ?array
   {
      $location_name = (!$location_name) ? $this->getSelectedLocationResourceName() : $location_name;
      try {
         // Validate required fields
         if (empty($post_data['summary'])) {
            throw new \Exception('Post summary is required');
         }
         // Build the post object according to API docs
         $post = [
            'languageCode' => $post_data['language_code'] ?? 'en-US',
            'summary' => $post_data['summary'],
            'topicType' => $post_data['topic_type'] ?? 'STANDARD'
         ];
         // Add optional fields
         if (!empty($post_data['call_to_action'])) {
            $post['callToAction'] = [
               'actionType' => $post_data['call_to_action']['type'] ?? 'LEARN_MORE',
               'url' => $post_data['call_to_action']['url']
            ];
         }
         if (!empty($post_data['media'])) {
            $post['media'] = $post_data['media'];
         }
         if ($post_data['topic_type'] === 'EVENT' && !empty($post_data['event'])) {
            $post['event'] = $post_data['event'];
         }
         if ($post_data['topic_type'] === 'OFFER' && !empty($post_data['offer'])) {
            $post['offer'] = $post_data['offer'];
         }
         $response = $this->makeRequest(
            'POST',
            "/v4/{$location_name}/localPosts",
            $post,
            'posts'
         );
         $this->cache->invalidate("posts_{$location_name}");
         return $response;
      } catch (\Exception $e) {
         $this->logError($e->getMessage(), [
            'method' => 'createPost'
         ]);
         return null;
      }
   }
   /**
    * Update an existing GMB post
    */
   public function updatePost(string $post_name, array $post_data): bool
   {
      try {
         $update_fields = [];
         $update_mask = [];
         if (isset($post_data['summary'])) {
            $update_fields['summary'] = $post_data['summary'];
            $update_mask[] = 'summary';
         }
         if (isset($post_data['call_to_action'])) {
            $update_fields['callToAction'] = $post_data['call_to_action'];
            $update_mask[] = 'callToAction';
         }
         if (empty($update_fields)) {
            return true; // Nothing to update
         }
         $response = $this->makeRequest(
            'PATCH',
            "/v4/{$post_name}?updateMask=" . implode(',', $update_mask),
            $update_fields,
            'posts'  // Use the posts API base
         );
         return !empty($response);
      } catch (\Exception $e) {
         $this->logError($e->getMessage(), [
            'method' => 'updatePost'
         ]);
      if (!$this->getAccessToken() || !$this->getAccountID()) {
         return false;
      }
   }
   /**
    * Delete a post
    */
   public function deletePost(string $post_name): bool
   {
      try {
         $this->makeRequest('DELETE', "/v1/accounts/{$post_name}", [], 'posts');
         return true;
      } catch (\Exception $e) {
         $this->logError($e->getMessage(), [
            'method' => 'deletePost'
         ]);
         return false;
      }
   }
   /****************
    * HOURS
    ***************/
   /**
    * Update regular business hours
    *
    * @param array $hours Array of periods with open/close times per day
    * @return array Success status, according to OperationQueue.php
    */
   public function updateBusinessHours(array $hours): array
   {
      if(!$this->isSetUp()) {
         return [];
      }
      $location_name = $this->credentials['location'];
      if (empty($location_name)) {
         error_log('[GMB] ERROR: No location selected or found');
         throw new \Exception('No location selected. Please select a location first.');
      }
      try {
         // Validate and format hours
         $periods = $this->validateAndFormatHours($hours);
         if (empty($periods)) {
            error_log('[GMB] ERROR: No valid periods found after validation');
            throw new \Exception('No valid business hours provided');
         }
         $update_data = [
            'regularHours' => [
               'periods' => $periods
            ]
         ];
         $endpoint = "/v1/{$location_name}?updateMask=regularHours";
         // Make the API request
         $response = $this->makeRequest(
            'PATCH',
            $endpoint,
            $update_data,
            'base'
         );
         $success = $response !== null;
         // Additional validation - check if the response contains the updated hours
         if ($success && $response) {
            if (isset($response['regularHours'])) {
//             error_log('[GMB] SUCCESS: Updated hours confirmed in response: ' . print_r($response['regularHours'], true));
            } else {
//             error_log('[GMB] WARNING: No regularHours in response, but API call succeeded');
//             error_log('[GMB] Full response keys: ' . implode(', ', array_keys($response)));
            }
         }
         return [
            'success'   => $success,
            'result' => $response
         ];
      } catch (\Exception $e) {
         $this->logError($e->getMessage(), [
            'method' => 'updateBusinessHours'
         ]);
         return [
            'success' => false,
            'result' => $e->getMessage()
         ];
      }
   }
   private function validateAndFormatHours(array $hours): array
   {
      $valid_days = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'];
      $periods = [];
      foreach ($hours as $day => $times) {
         $formatted_day = strtoupper($day);
         // Convert day names if needed
         $day_mapping = [
            'MON' => 'MONDAY',
            'TUE' => 'TUESDAY',
            'TUES' => 'TUESDAY',
            'WED' => 'WEDNESDAY',
            'THU' => 'THURSDAY',
            'THUR' => 'THURSDAY',
            'THURS' => 'THURSDAY',
            'FRI' => 'FRIDAY',
            'SAT' => 'SATURDAY',
            'SUN' => 'SUNDAY'
         ];
         if (isset($day_mapping[$formatted_day])) {
            $formatted_day = $day_mapping[$formatted_day];
         }
         if (!in_array($formatted_day, $valid_days)) {
            error_log('[GMB] Invalid day: ' . $day . ' (formatted: ' . $formatted_day . ')');
            continue;
         }
         // Check if day is open with flexible comparison
         $is_open = false;
         if (isset($times['open'])) {
            // Convert to boolean - handles string "1", integer 1, boolean true, etc.
            $is_open = (bool)(int)$times['open'];
         }
         if (!$is_open) {
            error_log('[GMB] Day ' . $formatted_day . ' is closed (open value: ' . ($times['open'] ?? 'not set') . ')');
            continue;
         }
         error_log('[GMB] Day ' . $formatted_day . ' is OPEN');
         $open_time = $this->formatTime($times['time_opens'] ?? '');
         $close_time = $this->formatTime($times['time_closes'] ?? '');
         if (empty($open_time) || empty($close_time)) {
            error_log('[GMB] Missing or invalid times for ' . $formatted_day);
            error_log('[GMB] Raw time data - time_opens: "' . ($times['time_opens'] ?? 'not set') . '", time_closes: "' . ($times['time_closes'] ?? 'not set') . '"');
            continue;
         }
         $period = [
            'openDay' => $formatted_day,
            'openTime' => $open_time,    // Now a proper Google TimeOfDay object
            'closeDay' => $formatted_day,
            'closeTime' => $close_time   // Now a proper Google TimeOfDay object
         ];
         $periods[] = $period;
      }
      return $periods;
   }
   /**
    * Set special/temporary hours (for holidays, special events, etc.)
    *
    * @param string $date
    * @param array{open?: float, closed?:float, isClosed?: bool, location?: string} $options
    * @return array Operation Queue [ 'success' => true/false, 'result' => $results]
    */
   public function setSpecialHours(string $date, array $options = []):array
   {
      // Get the full location resource name
      $location_name = $this->getSelectedLocationResourceName();
      if (empty($location_name)) {
         throw new \Exception('No location selected. Please select a location first.');
      }
      try {
         // Format the date correctly for Google API
         $date_obj = [
            'year' => (int)date('Y', strtotime($date)),
            'month' => (int)date('n', strtotime($date)),
            'day' => (int)date('j', strtotime($date))
         ];
         $special_hour_period = [
            'startDate' => $date_obj,
            'endDate' => $date_obj, // Same day for single-day special hours
         ];
         if (array_key_exists('isClosed', $options) && $options['isClosed']) {
            $special_hour_period['closed'] = true;
         } else {
            $special_hour_period['closed'] = false;
            // Format times as TimeOfDay objects
            if (!empty($options['open'])) {
               $open_time = $this->formatTime($options['open']);
               if ($open_time) {
                  $special_hour_period['openTime'] = $open_time;
               }
            }
            if (!empty($options['close'])) {
               $close_time = $this->formatTime($options['close']);
               if ($close_time) {
                  $special_hour_period['closeTime'] = $close_time;
               }
            }
         }
         // Get existing special hours and their periods
         $existing_special_hours = $location['specialHours'] ?? [];
         $existing_periods = $existing_special_hours['specialHourPeriods'] ?? [];
         // Add or update the special hour period
         $found = false;
         foreach ($existing_periods as &$existing_period) {
            if (isset($existing_period['startDate']) && $existing_period['startDate'] == $date_obj) {
               $existing_period = $special_hour_period;
               $found = true;
               break;
            }
         }
         if (!$found) {
            $existing_periods[] = $special_hour_period;
         }
         // Prepare the update data with correct structure
         $update_data = [
            'specialHours' => [
               'specialHourPeriods' => $existing_periods
            ]
         ];
         // Try the PATCH request
         $response = $this->makeRequest(
            'PATCH',
            "/v1/{$location_name}?updateMask=specialHours",
            $update_data,
            'base'
         );
         return [
            'success'   => $response !== null,
            'results'   => $response
         ];
      } catch (\Exception $e) {
         $this->logError($e->getMessage(), [
            'method' => 'setSpecialHours'
         ]);
         return [
            'success'   => false,
            'results'   => $e->getMessage(),
         ];
      }
   }
   /**
    * Clear all special hours
    */
   public function clearSpecialHours(string $location_name): bool
   {
      try {
         $update_data = [
            'specialHours' => []
         ];
         $response = $this->makeRequest(
            'PATCH',
            "/v1/{$location_name}?updateMask=specialHours",
            $update_data,
            'base'
         );
         return $response !== null;
      } catch (\Exception $e) {
         $this->logError($e->getMessage(), [
            'method' => 'clearSpecialHours'
         ]);
         return false;
      }
   }
   /***************************************************************
      HOURS
    ***************************************************************/
   /**
   FROM THE DOCS:
   UPLOAD FROM URL
   To upload photos from a URL , make the following call to Media.Create. Use the relevant category as needed.
   POST https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/media
   {
   "mediaFormat": "PHOTO",
   "locationAssociation": {
   "category": "COVER"
   },
   "sourceUrl": “http://example.com/biz/image.jpg",
   }
   To upload videos from a URL with the Google My Business API, make the following call to Media.Create:
   POST https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/media
   {
   "mediaFormat": "VIDEO",
   "locationAssociation": {
   "category": "ADDITIONAL"
   },
   "sourceUrl": “http://example.com/biz/video.mp4",
   }
    **/
   public function uploadImageToGMB(int $imgID, string $category = 'ADDITIONAL'): ?string
   {
      if ($imgID === 0) {
         return null;
      }
      if (empty($this->credentials)) {
         $this->loadCredentials();
      }
      try {
         $imgID = $this->getSupportedImage($imgID);
         $valid_categories = [
            'COVER', 'PROFILE', 'LOGO', 'EXTERIOR', 'INTERIOR',
            'PRODUCT', 'AT_WORK', 'FOOD_AND_DRINK', 'MENU',
            'COMMON_AREA', 'ROOMS', 'TEAMS', 'ADDITIONAL',
            'FOOD_AND_DRINK'
         ];
         if (!in_array($category, $valid_categories)) {
            throw new \Exception('Invalid photo category: ' . $category);
         }
         $mediaKey = $this->getImageMediaKey($imgID);
         if ($mediaKey) {
            return $mediaKey;
         }
         $image_url = wp_get_attachment_image_url($imgID, 'full');
         $media_data = [
            'mediaFormat' => 'PHOTO',
            'locationAssociation' => [
               'category' => $category
            ],
            'sourceUrl' => $image_url
         ];
         $response = $this->makeRequest(
            'POST',
            "/v4/{$this->credentials['account']}/{$this->credentials['location']}/media",
            $media_data,
            'posts'
         );
         if (!is_wp_error($response) && array_key_exists('name', $response)) {
            $mediaKey = $this->extractMediaKey($response['name']);
            $this->setImageMediaKey($imgID, $mediaKey);
            return $mediaKey;
         }
         return null;
      } catch (\Exception $e) {
         $this->logError($e->getMessage(), [
            'method' => 'uploadPhotoFromUrl'
         ]);
         return null;
      }
   }
   public function getImageMediaKey(int $imgID):string|false
   {
      $mediaKey = get_post_meta($imgID, BASE.'gmb_mediakey', true);
      if ($mediaKey !== '') {
         return $this->testGMBImage($mediaKey);
      }
      return false;
   }
   public function testGMBImage(string $mediaKey):string|false
   {
      $existing = $this->getLocationMedia();
      $exists = array_filter($existing, function ($item) use ($mediaKey) {
         return $this->extractMediaKey($item['name']) === $mediaKey;
      });
      return (empty($exists)) ? false : $mediaKey;
   }
   public function setImageMediaKey(int $imgID, string $key):bool
   {
      return update_post_meta($imgID, BASE.'gmb_mediakey', $key);
   }
   /***************************************************************
      CREDENTIALS
    ***************************************************************/
   /**
    * Get user's GMB accounts
    */
   public function getAccounts(bool $force = false): array
   {
      $ttl = 7 * 24 * 60 * 60; // week in seconds
      $response = $this->getRequest('/v1/accounts', [], 'accounts', $ttl, $force)??[];
      if (isset($response['accounts']) && is_array($response['accounts'])) {
         return $response['accounts'];
      }
      if (is_array($response) && isset($response['name'])) {
         return [$response];
      }
      return [];
   }
   /**
    * Get reviews for the current location
    * @param int $page_size Number of reviews to fetch (max 50)
    * @return array|null
    */
   public function getReviews(int $page_size = 5): ?array
   {
      $this->ensureInitialized();
      if (!$this->location) {
         throw new \Exception('No location selected');
      }
      if (!$this->account_id) {
         throw new \Exception('No account configured');
      }
      $location = $this->getSelectedLocationResourceName();
      $account = $this->account_id;
      // Check cache first (weekly refresh = 604800 seconds)
      $cache_key = ['reviews', $location, $page_size];
      $cached = $this->cache->get($cache_key);
      if ($cached !== false) {
         return $cached;
      }
      try {
         // Reviews endpoint from My Business Account Management API
         $response = $this->getRequest(
            "/{$account}/{$location}/reviews",
         return $this->getRequest(
            sprintf(
               '/v1/%s/locations',
               $this->getAccountID()
            ),
            [
               'orderBy' => 'updateTime desc'
            ],
            'v4'
               'readMask'  => $this->readMask
            ]
         );
         $reviews = $response ?? [];
         // Cache for 1 week (604800 seconds)
         $this->cache->set($cache_key, $reviews, WEEK_IN_SECONDS);
         return $reviews;
      } catch (\Exception $e) {
         $this->logError($e->getMessage(), [
            'method' => 'getReviews'
         ]);
         return null;
      }
   }
   /**
    * Get the URL to view all Google reviews for the current location
    * @return string|null The reviews viewing URL or null if not available
    */
   public function getReviewsViewUrl(): ?string
   {
      $this->ensureInitialized();
      try {
         $location = $this->getLocation();
         if (empty($location)) {
            return null;
         }
         // Prefer maps URL as it shows all reviews directly
         if (!empty($location['metadata']['mapsUrl'])) {
            return $location['metadata']['mapsUrl'];
         }
         // Fallback: construct from Place ID
         if (!empty($location['metadata']['placeId'])) {
            return 'https://search.google.com/local/reviews?placeid=' .
               urlencode($location['metadata']['placeId']);
         }
         return null;
      } catch (\Exception $e) {
         $this->logError('Failed to get reviews view URL: ' . $e->getMessage(), [
            'method' => 'getReviewsViewUrl'
         ]);
         return null;
      }
   }
   /**
    * Get the URL to leave a review for the current location
    * @return string|null The review URL or null if not available
    */
   public function getReviewUrl(): ?string
   {
      $this->ensureInitialized();
      try {
         $location = $this->getLocation();
         if (empty($location)) {
            return null;
         }
         // Try to use Place ID for write review
         if (!empty($location['metadata']['placeId'])) {
            return 'https://search.google.com/local/writereview?placeid=' .
               urlencode($location['metadata']['placeId']);
         }
         // Fallback to maps URL
         if (!empty($location['metadata']['mapsUrl'])) {
            return $location['metadata']['mapsUrl'] . '/reviews';
         }
         return null;
      } catch (\Exception $e) {
         $this->logError('Failed to get review URL: ' . $e->getMessage(), [
            'method' => 'getReviewUrl'
         ]);
         return null;
      }
   }
   /**
    * Get locations for an account (with persistent storage)
    * Allowed Fields: https://developers.google.com/my-business/content/location-data#list_of_all_supported_filter_fields
    */
   public function getLocations(?string $account_name = null, bool $force = false): array
   {
      if (!$this->isSetUp()) {
         return [];
      }
      $account_name = $account_name ?: $this->account_id;
      if (empty($account_name) || !str_starts_with($account_name, 'accounts/')) {
         return [];
      }
      $params = ['readMask' => $this->readMask];
      $ttl = 7 * 24 * 60 * 60; // Week in seconds
      try {
         $response = $this->getRequest("/v1/{$account_name}/locations", $params,'accounts', $ttl, $force);
         return isset($response['locations']) ? $response['locations'] : [];
      } catch (\Exception $e) {
         error_log('[GMB] Error getting locations for ' . $account_name . ': ' . $e->getMessage());
      } catch (Exception $e)
      {
         $this->logError('getLocations', 'Could not get locations: '.$e->getMessage());
         return [];
      }
   }
   public function handleRefreshAccessToken():WP_Error|array
   {
      if ($this->refreshOAuthToken()) {
         return [
            'success'   => true,
            'message'   => 'Successfully refreshed OAuth Token'
         ];
      }
      return new WP_Error('failure', 'Failed to Refresh token');
   }
   /**
    * Manually refresh accounts and locations data from API
    */
   public function refreshStoredData(): array
   {
      try {
         // Fetch fresh accounts data from API
         $accounts = $this->getAccounts(true);
         if (!empty($accounts)) {
            // Fetch and store locations for each account
            $locationsProcessed = 0;
            $accountsProcessed = count($accounts);
            foreach ($accounts as $account) {
               $locations = $this->getLocations($account['name'], true);
               if (!empty($locations)) {
                  $locationsProcessed += count($locations);
      protected function handleGetLocationsResponse(array $response, string $method):array
      {
         $locations = [];
         if (isset($response['locations'])) {
            foreach ($response['locations'] as $loc) {
               if (isset($loc['name']) && isset($loc['title'])) {
                  $locations[$loc['name']] = $loc['title'];
               }
            }
            return [
               'success' => true,
               'accounts' => $accountsProcessed,
               'locations' => $locationsProcessed,
               'message' => sprintf(
                  'Successfully refreshed %d accounts with %d total locations',
                  $accountsProcessed,
                  $locationsProcessed
               )
            ];
         }
         return [
            'success' => false,
            'message' => 'No accounts found'
         ];
      } catch (\Exception $e) {
         error_log('[GMB] Error refreshing stored data: ' . $e->getMessage());
         return [
            'success' => false,
            'message' => 'Error: ' . $e->getMessage()
         ];
      }
   }
   /**
    * Get a specific location details
    */
   public function getLocation(?string $location_name = null, bool $force = false):?array
   {
      if (!$this->isSetUp()) {
         return null;
      }
      $location_name = $location_name?:$this->credentials['location'];
      if (empty($location_name)) {
         error_log('[GMB] No location name provided for getLocation');
         return null;
         return $locations;
      }
      $params = [
         'readMask' => $this->readMask
      ];
      $endpoint = "/v1/{$location_name}?".http_build_query($params);
      $location = $this->getRequest($endpoint, [], 'base', 'moderate', $force);
      return $location??null;
   }
   /**
    * Get the currently selected location
    */
   public function getSelectedLocation(): ?string
   protected function getAccounts(bool $force = false):array|false
   {
      return $this->credentials['location'];
   }
   /**
    * Set the selected location
    */
   public function setSelectedLocation(string $location): bool
   {
      $this->credentials['location'] = $location;
      if (preg_match('/accounts\/([^\/]+)\//', $location, $matches)) {
         $this->account_id = 'accounts/' . $matches[1];
      }
      $credentials = $this->credentials;
      $credentials['location'] = $location;
      $credentials['account'] = $this->account_id;
      return $this->saveCredentials($credentials)['success'];
   }
   /*************************************************************
   RENDER
    *************************************************************/
   /**
    * Render business list for connected users
    */
   private function renderBusinessList(): void
   {
      try {
         $accounts = $this->getAccounts();
         if (empty($accounts)) {
            echo '<p>No business accounts found.</p>';
            return;
         }
         ?>
         <div class="gmb-business-list">
            <h4>Your Business Locations</h4>
            <?php
            foreach ($accounts as $account) {
               $locations = $this->getLocations($account['name']);
               foreach ($locations as $location) {
                  ?>
                  <div class="business-location">
                     <strong><?php echo esc_html($location['title']); ?></strong>
                     <?php if ($this->isSelectedLocation($location)): ?>
                        <span class="selected-badge">✓ Selected</span>
                     <?php endif; ?>
                  </div>
                  <?php
               }
            }
            ?>
         </div>
         <?php
      } catch (\Exception $e) {
         echo '<p>Error loading business locations.</p>';
      }
   }
   private function isSelectedLocation(array $location): bool
   {
      if (empty($this->credentials['location'])) {
      if (!$this->getAccessToken()) {
         return false;
      }
      return ($location['name'] === $this->credentials['location']);
   }
   /***********************************************************
      UTILITY
    ***********************************************************/
   /**
    * Helper method to strip HTML tags from text
    * Preserves plain text content while removing all HTML formatting
    */
   protected function stripHtmlTags(string $text): string
   {
      // Remove HTML tags
      $text = wp_strip_all_tags($text);
      // Decode HTML entities
      $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
      // Clean up whitespace
      $text = preg_replace('/\s+/', ' ', $text);
      // Trim
      return trim($text);
   }
   /**
    * Format date for GMB API
    */
   protected function formatDate(string $date): array
   {
      $timestamp = strtotime($date);
      return [
         'year' => (int)date('Y', $timestamp),
         'month' => (int)date('n', $timestamp),
         'day' => (int)date('j', $timestamp)
      ];
   }
   /**
    * Format time for Google My Business API (HH:MM format)
    */
   private function formatTime(string $time): ?array
   {
      if (empty($time)) {
         return null;
      }
      // Remove any extra whitespace
      $time = trim($time);
      // Handle different time formats and convert to 24-hour format
      if (preg_match('/^(\d{1,2}):(\d{2})(?:\s*(AM|PM))?$/i', $time, $matches)) {
         $hour = (int)$matches[1];
         $minute = (int)$matches[2];
         $ampm = isset($matches[3]) ? strtoupper($matches[3]) : '';
         // Convert 12-hour to 24-hour format if needed
         if ($ampm === 'PM' && $hour !== 12) {
            $hour += 12;
         } elseif ($ampm === 'AM' && $hour === 12) {
            $hour = 0;
         }
         // Validate hour and minute ranges
         if ($hour >= 0 && $hour <= 23 && $minute >= 0 && $minute <= 59) {
            return [
               'hours' => $hour,
               'minutes' => $minute
            ];
         }
      }
      error_log('[GMB] Invalid time format: ' . $time);
      return null;
   }
   /*****************************************************************************
    *
    * OAUTH IMPLEMENTATION
    *
   *****************************************************************************/
   private function getScopes(): string
   {
      return implode(' ', [
         'https://www.googleapis.com/auth/business.manage',
         'https://www.googleapis.com/auth/userinfo.email',
         'https://www.googleapis.com/auth/userinfo.profile'
      ]);
   }
   /**
    * Handle account selection
    */
   public function handleSelectAccount($data = null): array
   {
      try {
         $account_name = null;
         // Extract account name from various sources
         if (is_array($data)) {
            $account_name = $data['account'] ?? $data['account'] ?? null;
         } elseif (isset($_POST['account'])) {
            $account_name = sanitize_text_field($_POST['account']);
         }
         if (empty($account_name)) {
            return [
               'success'   => false,
               'message'   => 'No Account Selected'
            ];
         }
         // Get account details
         $accounts = $this->getAccounts();
         $selected_account = null;
         foreach ($accounts as $account) {
            if ($account['name'] === $account_name) {
               $selected_account = $account;
               break;
            }
         }
         if (!$selected_account) {
            throw new \Exception('Invalid account selected');
         }
         // Update credentials with selected account
         $this->credentials['account'] = $account_name;
         $this->credentials['selected_account_id'] = $selected_account['accountId'] ?? '';
         $this->saveCredentials($this->credentials);
         return [
            'success' => true,
            'message' => 'Account selected successfully',
            'reload' => true
         ];
      } catch (\Exception $e) {
         $this->logError('Failed to select account', [
            'error' => $e->getMessage(),
            'data' => $data
         ]);
         return [
            'success' => false,
            'message' => $e->getMessage()
         ];
      }
   }
   /**
    * Handle location selection
    */
   public function handleSelectLocation($data = null): array
   {
      try {
         $location_name = null;
         // Extract location name from various sources
         if (is_array($data)) {
            $location_name = $data['location'] ?? $data['location'] ?? null;
         } elseif (isset($_POST['location'])) {
            $location_name = sanitize_text_field($_POST['location']);
         }
         if (empty($location_name)) {
            throw new \Exception('No location selected');
         }
         // Verify this is a valid location for the selected account
         if (empty($this->credentials['account'])) {
            throw new \Exception('No account selected. Please select an account first.');
         }
         $locations = $this->getLocations($this->credentials['account']);
         $valid_location = false;
         foreach ($locations as $location) {
            if ($location['name'] === $location_name) {
               $valid_location = true;
               $this->credentials['location_id'] = $location['locationId'] ?? '';
               break;
            }
         }
         if (!$valid_location) {
            throw new \Exception('Invalid location selected');
         }
         // Update credentials with selected location
         $this->credentials['location'] = $location_name;
         // Save updated credentials
         $this->saveCredentials($this->credentials);
         // Initialize with new location
         $this->credentials['location'] = $location_name;
         return [
            'success' => true,
            'message' => 'Location selected successfully',
            'reload' => false  // No need to reload for location selection
         ];
      } catch (\Exception $e) {
         $this->logError('Failed to select location', [
            'error' => $e->getMessage(),
            'data' => $data
         ]);
         return [
            'success' => false,
            'message' => $e->getMessage()
         ];
      }
   }
   /**
    * Check OAuth status
    */
   public function checkOAuthStatus(): array
   {
      try {
         $valid = $this->isOAuthValid();
         return [
            'success' => true,
            'valid' => $valid,
            'expires_at' => $this->credentials['expires_at'] ?? null,
            'has_refresh_token' => !empty($this->credentials['refresh_token'])
         ];
      } catch (\Exception $e) {
         return [
            'success' => false,
            'valid' => false,
            'message' => $e->getMessage()
         ];
      }
   }
   /********************************************************************
    *
    * ADMIN UI
    *
   ********************************************************************/
   /**
    * Updated renderOAuthConnectedOptions for better UI
    */
   protected function renderOAuthConnectedOptions(): void
   {
      if (!$this->isSetUp()) {
         return;
      }
      // Check OAuth status
      $oauth_valid = $this->isOAuthValid();
      if (!$oauth_valid) {
         ?>
         <div class="oauth-status">
            <span class="status-invalid">⚠️ OAuth token expired - please reconnect</span>
         </div>
         <?php
      }
      // Show account selection
      $accounts = $this->getAccounts();
      if (!empty($accounts)) {
         ?>
         <div class="form-field" data-service="gmb">
            <label for="gmb_account">Google Business Account</label>
            <select name="account"
                  id="gmb_account"
                  class="jvb-ajax-update"
                  data-action="select_account">
               <option value="">Select an account...</option>
               <?php foreach ($accounts as $account): ?>
                  <option value="<?php echo esc_attr($account['name']); ?>"
                     <?php selected($credentials['account'] ?? '', $account['name']); ?>>
                     <?php echo esc_html($account['accountName'] ?? $account['name']); ?>
                  </option>
               <?php endforeach; ?>
            </select>
         </div>
         <?php
         // Show location selection if account is selected
         if (!empty($this->credentials['account'])) {
            $locations = $this->getLocations($this->credentials['account']);
            if (!empty($locations)) {
               ?>
               <div class="form-field location-field visible" data-service="gmb">
                  <label for="gmb_location">Business Location</label>
                  <select name="location"
                        id="gmb_location"
                        class="jvb-ajax-update"
                        data-action="select_location">
                     <option value="">Select a location...</option>
                     <?php foreach ($locations as $location): ?>
                        <option value="<?php echo esc_attr($location['name']); ?>"
                           <?php selected($this->credentials['location'] ?? '', $location['name']); ?>>
                           <?php echo esc_html($location['title'] ?? $location['name']); ?>
                        </option>
                     <?php endforeach; ?>
                  </select>
               </div>
               <?php
            }
         }
         // Show current selection status
         if (!empty($this->credentials['location'])) {
            ?>
            <div class="connection-status connected">
               ✅ Connected to: <?php echo esc_html($this->credentials['location']); ?>
            </div>
            <?php
         }
      } else {
         ?>
         <div class="notice notice-warning">
            <p>No Google Business accounts found. Please ensure your Google account has access to Google My Business.</p>
         </div>
         <?php
      }
   }
   public function getServiceDescription(): string
   {
      return "Manage your Google My Business listings, posts, hours, and reviews.";
   }
   /*****************************************************************************
    *
    * FOOD MENU IMPLEMENTATION
    *
    *****************************************************************************/
   /**
    * Get food menus for the current location
    */
   public function getFoodMenus(): ?array
   {
      if (!$this->credentials['location']) {
         throw new \Exception('No location selected. Please select a location first.');
      }
      $response = $this->getRequest(
         "/v4/{$this->credentials['location']}/foodMenu",
         [],
         'posts'
      );
      return $response['foodMenus'] ?? [];
   }
   /**
    * Update food menus for the current location
    */
   public function updateFoodMenus(array $menus): bool
   {
      if (!$this->credentials['location']) {
         throw new \Exception('No location selected. Please select a location first.');
      }
      try {
         $food_menus = ['menus' => $menus];
         $endpoint = "/{$this->credentials['account']}/{$this->credentials['location']}/foodMenus";
         // Use dedicated Food Menu API
         $response = $this->makeRequest(
            'PATCH',
            $endpoint,
            $food_menus,
            'v4'
         );
         $this->logDebug('[GMB]Finished updating food menu', [
            'method' => 'updateFoodMenus',
            'response'  => $response,
         ]);
         return !empty($response);
      } catch (\Exception $e) {
         $this->logError($e->getMessage(), [
            'method' => 'updateFoodMenus'
         ]);
         return false;
      }
   }
   /**
    * Upload menu images and return a map of identifiers to mediaKeys
    */
   protected function uploadAndMapMenuImages(array $menus): array
   {
      $mediaKeyMap = [];
      // Process all menu items for images
      foreach ($menus as $menu) {
         foreach ($menu['sections'] ?? [] as $section) {
            // Upload section images if needed (though API doesn't support section images yet)
            if (!empty($section['image']) && is_numeric($section['image'])) {
               $mediaKey = $this->uploadImageToGMB($section['image'], 'FOOD_AND_DRINK');
               if ($mediaKey) {
                  $mediaKeyMap['section_' . $section['id']] = $mediaKey;
               }
            }
            // Process menu items
            foreach ($section['items'] ?? [] as $item) {
               // Upload featured image
               if (!empty($item['post_id'])) {
                  $featured_image_id = get_post_thumbnail_id($item['post_id']);
                  if ($featured_image_id) {
                     $mediaKey = $this->uploadImageToGMB($featured_image_id, 'FOOD_AND_DRINK');
                     if ($mediaKey) {
                        $mediaKeyMap['item_' . $item['post_id']] = $mediaKey;
                     }
                  }
                  // Upload gallery images if available
                  if (!empty($item['gallery_ids'])) {
                     foreach ($item['gallery_ids'] as $gallery_id) {
                        $mediaKey = $this->uploadImageToGMB($gallery_id, 'FOOD_AND_DRINK');
                        if ($mediaKey) {
                           $mediaKeyMap['gallery_'.$gallery_id] = $mediaKey;
                        }
                     }
                  }
               }
            }
         }
      }
      return $mediaKeyMap;
   }
   /**
    * Extract mediaKey from the full media resource name
    */
   protected function extractMediaKey(string $mediaResourceName): string
   {
      // Extract mediaKey from format: accounts/{accountId}/locations/{locationId}/media/{mediaKey}
      $parts = explode('/media/', $mediaResourceName);
      return isset($parts[1]) ? $parts[1] : '';
   }
   /**
    * Get all media items for the current location
    */
   public function getLocationMedia(): array
   {
      if (!$this->credentials['location']) {
         throw new \Exception('No location selected. Please select a location first.');
      }
      try {
         $response = $this->getRequest(
            "/{$this->credentials['account']}/{$this->credentials['location']}/media",
            [],
            'v4'
         );
         return $response['mediaItems'] ?? [];
      } catch (\Exception $e) {
         $this->logError('Failed to get location media: ' . $e->getMessage());
         return [];
      }
   }
   public function handleGetAllLocations():WP_Error|array
   {
      try {
         // Check if we should force refresh
         $force = isset($_POST['force']) && $_POST['force'] === 'true';
         // Get accounts (use cache unless forced)
         $accounts = $this->getAccounts($force);
         $all_locations = [];
         foreach ($accounts as $account) {
            // Get locations for each account (use cache unless forced)
            $locations = $this->getLocations($account['name'], $force);
            $all_locations = array_merge($all_locations, $locations);
         }
         return [
            'success'   => true,
            'locations' => $all_locations,
            'cached' => !$force
         ];
      } catch (\Exception $e) {
         return new WP_Error('failure', 'Something went wrong: '.$e->getMessage());
      }
   }
   /**
    * AJAX handler for updating selected location
    */
   public function handleUpdateLocation($data = null): array
   {
      try {
         // Handle both AJAX and REST API calls
         $selected_account = null;
         if (is_array($data)) {
            $selected_account = $data['account'] ?? $data['location'] ?? null;
         } elseif (isset($_POST['account'])) {
            $selected_account = sanitize_text_field($_POST['account']);
         }
         if (empty($selected_account)) {
            throw new \Exception('No account selected');
         }
         // Save the selected account
         $credentials = $this->credentials;
         $credentials['account'] = $selected_account;
         // Get locations for this account
         $locations = $this->getLocations($selected_account);
         // Auto-select first location if only one exists
         if (count($locations) === 1) {
            $credentials['location'] = $locations[0]['name'];
         }
         $this->saveCredentials($credentials);
         return [
            'success' => true,
            'message' => 'Account selected successfully',
            'locations' => $locations,
            'reload' => true  // Trigger page refresh to show location dropdown
         ];
      } catch (\Exception $e) {
         $this->logError('Failed to update location', [
            'error' => $e->getMessage(),
            'data' => $data
         ]);
         return [
            'success' => false,
            'message' => $e->getMessage()
         ];
      }
   }
   public function clearStoredData(): bool
   {
      try {
         // Use the static method to clear the entire cache group
         $this->cache->flush();
         return true;
      } catch (\Exception $e) {
         error_log('[GMB] Error clearing stored data: ' . $e->getMessage());
         return false;
      }
   }
   public function getPriceLists(): ?array
   {
      if (!$this->credentials['location']) {
         throw new \Exception('No location selected. Please select a location first.');
      }
      try {
         $location_data = $this->getLocation($this->credentials['location']);
         return $location_data['priceLists'] ?? [];
      } catch (\Exception $e) {
         $this->logError($e->getMessage(), [
            'method' => 'getPriceLists'
         ]);
         return null;
      }
   }
   /**
    * Get insights/analytics
    */
   public function getInsights(string $location_name, string $start_date, string $end_date): ?array
   {
//    From the Docs:
//    Basic insights
//    Retrieves basic insights for a given list of locations. Use the accounts.locations.reportInsights API to return the insights that are associated with a location.
//
//       To return the basic insights associated with a location, use the following:
//
//    HTTP
//
//    POST
//    https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations:reportInsights
//    {
//       "locationNames": [
//       "accounts/{accountId}/locations/{locationId}"
//    ],
//      "basicRequest": {
//       "metricRequests": [
//              {
//                 "metric": "QUERIES_DIRECT"
//              },
//              {
//                 "metric": "QUERIES_INDIRECT"
//              }
//            ],
//            "timeRange": {
//          "startTime": "2016-10-12T01:01:23.045123456Z",
//                "endTime": "2017-01-10T23:59:59.045123456Z"
//            }
//       }
//    }
         $params = [
            'dailyMetrics' => 'BUSINESS_IMPRESSIONS_DESKTOP_MAPS,BUSINESS_IMPRESSIONS_DESKTOP_SEARCH,BUSINESS_IMPRESSIONS_MOBILE_MAPS,BUSINESS_IMPRESSIONS_MOBILE_SEARCH,BUSINESS_DIRECTION_REQUESTS,CALL_CLICKS,WEBSITE_CLICKS',
            'dailyRange.startDate.year' => date('Y', strtotime($start_date)),
            'dailyRange.startDate.month' => date('n', strtotime($start_date)),
            'dailyRange.startDate.day' => date('j', strtotime($start_date)),
            'dailyRange.endDate.year' => date('Y', strtotime($end_date)),
            'dailyRange.endDate.month' => date('n', strtotime($end_date)),
            'dailyRange.endDate.day' => date('j', strtotime($end_date))
         ];
      return $this->getRequest(
         "/v1/{$location_name}:fetchMultiDailyMetricsTimeSeries?" . http_build_query($params),
         $params,
         'performance'
         'https://mybusinessaccountmanagement.googleapis.com/v1/accounts',
         [],
         $force
      );
   }
   /**
    * Helper: Format hours for GMB API
    */
   private function formatHoursPeriods(array $hours): array
   {
      $periods = [];
      $days = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
      foreach ($hours as $day => $times) {
         if (empty($times['open']) || empty($times['close'])) {
            continue;
      protected function handleGetAccountsResponse(array $response, string $method):array
      {
         $accounts = [];
         if (isset($response['accounts']) && is_array($response['accounts'])) {
            $accounts = $response['accounts'];
         } elseif (is_array($response) && isset($response['name'])) {
            $accounts = [$response];
         }
         $periods[] = [
            'openDay' => $days[$day] ?? $day,
            'openTime' => $times['open'], // Format: "09:00"
            'closeDay' => $days[$day] ?? $day,
            'closeTime' => $times['close'] // Format: "17:00"
         ];
      }
      return $periods;
   }
   /**
    * HELPER: Easy interface method for creating a simple post
    * JVB()->connect('gmb')->createSimplePost("Check out our new menu!", "https://example.com/menu")
    */
   public function createSimplePost(string $message, ?string $cta_url = null, string $cta_type = 'LEARN_MORE'): ?array
   {
      $location_name = $this->getSelectedLocationResourceName();
      if (empty($location_name)) {
         throw new \Exception('No location selected. Please select a location first.');
      }
      $post_data = [
         'summary' => $message,
         'topic_type' => 'STANDARD'
      ];
      if ($cta_url) {
         $post_data['call_to_action'] = [
            'type' => $cta_type,
            'url' => $cta_url
         ];
      }
      return $this->createPost($location_name, $post_data);
   }
   /**
    * HELPER: Easy interface method for updating basic business info
    * JVB()->connect('gmb')->updateInfo(['title' => 'New Business Name', 'websiteUri' => 'https://newsite.com'])
    */
   public function updateInfo(array $updates): bool
   {
      $location_name = $this->getSelectedLocationResourceName();
      if (empty($location_name)) {
         throw new \Exception('No location selected. Please select a location first.');
      }
      return $this->updateBusinessInfo($location_name, $updates);
   }
   /**
    * HELPER: Easy interface for clearing special hours
    * JVB()->connect('gmb')->clearAllSpecialHours()
    */
   public function clearAllSpecialHours(): bool
   {
      $location_name = $this->getSelectedLocationResourceName();
      if (empty($location_name)) {
         throw new \Exception('No location selected. Please select a location first.');
      }
      return $this->clearSpecialHours($location_name);
   }
   protected function mappedMenuFields(string $postType):array
   {
      /**
       * @param array $fields
       * @param string $fields.name Thing
       */
      return apply_filters(
         'jvbGMBFields',
      [
         'name'         => 'post_title',
         'price'        => 'price',
         'description'  => 'post_excerpt',
         'nutritionFacts'=> 'nutritionFacts',
         'servesNumPeople'=>'servesNumPeople',
         'portionSize'  => 'portionSize',
         'preparationMethods' => 'preparationMethods',
         'cuisines'     => 'cuisines',
         'spiciness'    => 'spiciness',
         'allergen'     => 'allergen',
         'dietaryRestriction'=>'dietaryRestriction'
      ],
      jvbNoBase($postType));
   }
   /**
    * Build menu structure from WordPress posts
    */
   protected function collectMenu(array $menu_items): array
   {
      $defaultMeta = Meta::forOptions($this->userID.'_integrations');
      $defaults = ['menu_name', 'menu_description', 'default_section', 'cuisines', 'source_url', 'language', 'default_currency'];
      $defaults = $defaultMeta->getAll($defaults);
      // Group items by section
      $sections_map = [];
      foreach ($menu_items as $item) {
         $gmb_item = $this->buildMenuItem($item, $defaults);
         // Get section for this item
         $categories = wp_get_post_terms($item->ID, BASE.'section');
         $section_slug = !empty($categories) ?
            $categories[0]->slug :
            'menu-items';
         $section_name = !empty($categories) ?
            $categories[0]->name :
            ($defaults['default_section'] ?? 'Menu Items');
         if (!isset($sections_map[$section_slug])) {
            $sections_map[$section_slug] = [
               'labels' => [
                  'displayName' => $section_name,
                  'languageCode' => $defaults['language'] ?? 'en',
               ],
               'items' => [],
            ];
            if (!empty($section['description'])) {
               $sections_map[$section_slug]['labels']['description'] = $this->stripHtmlTags($section['description']);
         $options = [];
         foreach ($accounts as $account) {
            if (isset($account['name']) && isset($account['accountName'])) {
               $options[$account['name']] = $account['accountName'];
            }
         }
         $sections_map[$section_slug]['items'][] = $gmb_item;
         return $options;
      }
      $gmb_sections = $this->getMenuSectionsOrder($sections_map);
      // Build complete menu
      $gmb_menu = [
         'cuisines' => $this->getMenuCuisines($menu_items),
         'labels' => [
            'displayName' => $defaults['menu_name'] ?? 'Our Menu',
            'languageCode' => $defaults['language'] ?? 'en',
         ],
         'sections' => $gmb_sections
      ];
      if (array_key_exists('menu_description', $defaults)) {
         $gmb_menu['labels']['description'] = $this->stripHtmlTags($defaults['menu_description']);
      }
      // Add source URL if configured
      if (!empty($defaults['source_url'])) {
         $gmb_menu['source_url'] = $defaults['source_url'];
      }
      return [$gmb_menu]; // GMB expects an array of menus
   }
   protected function buildMenuItem(\WP_Post $item, array $defaults):array
   {
      $meta = Meta::forPost($item->ID);
      $fields = $this->mappedMenuFields($item->post_type);
      $values = $meta->getAll(array_values($fields));
      // Build GMB-formatted item
      $gmb_item = [
         'labels' => [
            'displayName' => $item->post_title,
            'description' => !empty($item->post_excerpt) ?
               $this->stripHtmlTags($item->post_excerpt) :
               $this->stripHtmlTags(wp_trim_words($item->post_content, 20)),
            'languageCode' => $defaults['language'] ?? 'en'
         ],
         'attributes'   => []
      ];
      // Process mapped fields
      foreach ($fields as $googleField => $wpField) {
         if (in_array($wpField, ['post_title', 'post_excerpt'])) {
            continue;
         }
         $value = $values[$wpField] ?? null;
         if (empty($value)) {
            continue;
         }
         // Process field based on type
         $processed = $this->processMenuField($googleField, $value, $values);
         if ($processed !== null) {
            $gmb_item['attributes'] = array_merge($gmb_item['attributes'], $processed);
         }
      }
      $featuredImage = get_post_thumbnail_id($item->ID);
      $mediaKey = $this->uploadImageToGMB($featuredImage, 'FOOD_AND_DRINK');
      if ($mediaKey) {
         $gmb_item['attributes'] = array_merge($gmb_item['attributes'], ['mediaKeys' => $mediaKey]);
      }
      if (empty($gmb_item['attributes'])) {
         unset($gmb_item['attributes']);
      }
      return $gmb_item;
   }
   /**
    * Process a single menu field value
    */
   protected function processMenuField(string $fieldName, $value, array $allValues): ?array
   {
      switch ($fieldName) {
         case 'price':
            if (is_numeric($value)) {
               return [
                  'price' => [
                     'currencyCode' => $allValues['price_currency'] ?? 'CAD',
                     'units' => intval($value),
                     'nanos' => (($value - intval($value)) * 1000000000)
                  ]
               ];
            }
            break;
         case 'nutritionFacts':
            if (is_array($value)) {
               return ['nutritionFacts' => $this->processNutritionData($value)];
            }
            break;
         case 'allergen':
            $allergens = $this->processListField($value);
            return $allergens ? ['allergens' => $allergens] : null;
         case 'dietaryRestriction':
            $restrictions = $this->processListField($value);
            return $restrictions ? ['dietaryRestrictions' => $restrictions] : null;
         case 'servesNumPeople':
            return ['servesNumPeople' => intval($value)];
         case 'portionSize':
            return ['portionSize' => $value];
         case 'preparationMethods':
            $methods = $this->processListField($value);
            return $methods ? ['preparationMethods' => $methods] : null;
         case 'cuisines':
            $cuisines = $this->processListField($value, 'strtoupper');
            return $cuisines ? ['cuisines' => $cuisines] : null;
         case 'spiciness':
            return ['spiciness' => strtoupper($value)];
         default:
            return [$fieldName => $value];
      }
      return null;
   }
   /**
    * Process list fields (arrays or comma-separated strings)
    */
   protected function processListField($value, ?callable $transform = null): ?array
   {
      if (empty($value)) {
         return null;
      }
      // Convert to array if string
      if (is_string($value)) {
         $value = array_map('trim', explode(',', $value));
      }
      // Filter empty values
      $value = array_values(array_filter($value));
      // Apply transformation if provided
      if ($transform && !empty($value)) {
         $value = array_map($transform, $value);
      }
      return !empty($value) ? $value : null;
   }
   protected function getMenuSectionsOrder(array $sections_map):array
   {
      $optionsMeta = Meta::forOptions('options');
      $sectionOrder = $optionsMeta->get('menu_section_order');
      // Build final GMB menu structure
      $ordered = [];
      // First add sections in configured order
      foreach ($sectionOrder as $section) {
         $slug = sanitize_title($section['name']);
         if (isset($sections_map[$slug]) && !empty($sections_map[$slug]['items'])) {
            $ordered[$slug] = $sections_map[$slug];
            unset($sections_map[$slug]);
         }
      }
      // Add any remaining sections
      foreach ($sections_map as $slug => $section) {
         if (!empty($section['items'])) {
            $ordered[$slug] = $section;
         }
      }
      return array_values($ordered);
   }
   private function getMenuCuisines(array $menu_items, array $defaults = []): array
   {
      $cuisines = [];
      // Check if there's a default cuisine
      if (!empty($defaults['cuisines'])) {
         $cuisines = is_array($defaults['cuisines']) ?
            $defaults['cuisines'] :
            [$defaults['cuisines']];
      }
      // Collect cuisines from individual items if specified
      foreach ($menu_items as $item) {
         $meta = Meta::forPost($item->ID);
         $item_cuisines = $meta->get('cuisines');
         if (!empty($item_cuisines)) {
            $item_cuisines = is_array($item_cuisines) ?
               $item_cuisines :
               array_map('trim', explode(',', $item_cuisines));
            foreach ($item_cuisines as $cuisine) {
               $cuisine = strtoupper(trim($cuisine));
               if (!in_array($cuisine, $cuisines)) {
                  $cuisines[] = $cuisine;
               }
            }
         }
      }
      // Ensure we have at least one cuisine (GMB requires it)
      if (empty($cuisines)) {
         $cuisines = ['AMERICAN'];
      }
      return array_map('strtoupper', $cuisines);
   }
   /**
    * Format nutrition facts for GMB API
    */
      protected function processNutritionData(array $nutritionData): array
   {
      $processed = [];
      // Handle both repeater format and simple array format
      if (isset($nutritionData[0]) && is_array($nutritionData[0])) {
         // Repeater format
         foreach ($nutritionData as $fact) {
            if (!empty($fact['name']) && isset($fact['amount'])) {
               $processed[] = [
                  'name' => $fact['name'],
                  'amount' => floatval($fact['amount']),
                  'unit' => $fact['unit'] ?? 'GRAM'
               ];
            }
         }
      } else {
         // Simple associative array format
         foreach ($nutritionData as $name => $value) {
            if (!empty($value)) {
               $unit = 'GRAM';
               if ($name === 'calories') {
                  $unit = 'CALORIE';
               } elseif (in_array($name, ['cholesterol', 'sodium', 'totalCarbohydrate', 'protein'])) {
                  $unit = 'MILLIGRAM';
               }
               $processed[] = [
                  'name' => $name,
                  'amount' => floatval($value),
                  'unit' => $unit
               ];
            }
         }
      }
      return $processed;
   }
   protected function setContentTypes():void
   {
      $this->has_content = true;
      $this->contentTypes = [
         'menu_item' => [
            'nutritionFacts'  => [
               'type'   => 'repeater',
               'label'  => 'Nutrition',
               'fields' => [
                  'name'   => [
                     'type'   => 'select',
                     'label'  => 'Name',
                     'options'   => [
                        'calories' => 'Calories',
                        'totalFat'  => 'Total Fat',
                        'cholesterol' => 'Cholesterol',
                        'sodium' => 'Sodium',
                        'totalCarbohydrate' => 'Total Carbohydrates',
                        'protein'   => 'Protein'
                     ]
                  ],
                  'amount' => [
                     'type'   => 'number',
                     'label'  => 'Amount',
                  ],
                  'unit'   => [
                     'type' => 'select',
                     'label'  => 'Unit',
                     'options'   => [
                        'MILLIGRAM' => 'Milligram',
                        'GRAM'   => 'Gram',
                        'CALORIE'   => 'Calorie'
                     ]
                  ]
               ],
               'description'  => 'Recommended. Example: Total Fat: 3g',
               'section'   => 'gmb',
            ],
            'servesNumPeople' => [
               'type'   => 'number',
               'label'  => 'Number of People this Serves',
               'bulkEdit'  => true,
               'section'   => 'gmb',
            ],
            'portionSize'  => [
               'type'   => 'text',
               'label'  => 'Portion size',
               'description' => 'Example: 8-piece of nuggets',
               'bulkEdit'  => true,
               'section'   => 'gmb',
            ],
            'preparationMethods' => [
               'type'   => 'textarea',
               'label'  => 'Preparation Methods',
               'description'  => 'Specific methods that the food can be prepared in',
               'bulkEdit'  => true,
               'section'   => 'gmb',
            ],
            'cuisines'  => [
               'type'   => 'text',
               'label'  => 'Cuisines',
               'description'  => 'Recommended. The specific cuisine of the food item',
               'bulkEdit'  => true,
               'section'   => 'gmb',
            ],
            'spiciness' => [
               'type'   => 'select',
               'label'  => 'Spiciness',
               'options'   => [
                  'none'   => 'None',
                  'mild'   => 'Mild',
                  'medium' => 'Medium',
                  'hot' => 'Hot',
               ],
               'bulkEdit'  => true,
               'section'   => 'gmb',
            ],
            'allergen'  => [
               'type'   => 'checkbox',
               'label'  => 'Allergens',
               'options'   => [
                  'dairy'  => 'Dairy',
                  'egg' => 'Egg',
                  'fish'   => 'Fish',
                  'peanut' => 'Peanut',
                  'shellfish' => 'Shellfish',
                  'soy'    => 'Soy',
                  'tree nut'  => 'Tree Nut',
                  'wheat'     => 'Wheat'
               ],
               'description'  => 'Recommended',
               'bulkEdit'  => true,
               'section'   => 'gmb',
            ],
            'dietaryRestriction' => [
               'type'   => 'checkbox',
               'label'  => 'Dietary Restrictions',
               'description' => 'Recommended.',
               'options'   => [
                  'halal' => 'Halal',
                  'kosher' => 'Kosher',
                  'organic'   => 'Organic',
                  'vegan'     => 'Vegan',
                  'vegetarian'   => 'Vegetarian',
                  'gluten-free'  => 'Gluten Free'
               ],
               'bulkEdit'  => true,
               'section'   => 'gmb',
            ]
         ],
         'post'   => [
            'summary' => [
               'type' => 'textarea',
               'label' => 'Post Summary',
               'required' => true
            ],
            'language_code' => [
               'type' => 'text',
               'label' => 'Language',
               'default' => 'en-CA'
            ],
            'media' => [
               'type' => 'image',
               'label' => 'Photo',
            ],
            'call_to_action' => [
               'type' => 'group',
               'fields' => [
                  'type' => [
                     'type' => 'select',
                     'options' =>
                        [
                           'BOOK' => 'Book Now',
                           'ORDER' => 'Order Now',
                           'SHOP' => 'Shop Now',
                           'LEARN_MORE' => 'Learn More',
                           'SIGN_UP' => 'Sign up',
                           'CALL' => 'Call'
                        ]
                  ],
                  'url' => [
                     'type' => 'url',
                     'label' => 'Action URL'
                  ]
               ]
            ]
         ],
         'event' => [
            'summary' => [
               'type' => 'textarea',
               'label' => 'Event Description',
               'required' => true
            ],
            'event' => [
               'title' => [
                  'type' => 'text',
                  'label' => 'Event Title',
                  'required' => true
               ],
               'start_date' => [
                  'type' => 'date',
                  'label' => 'Start Date',
                  'required' => true
               ],
               'end_date' => [
                  'type' => 'date',
                  'label' => 'End Date'
               ],
               'start_time' => [
                  'type' => 'time',
                  'label' => 'Start Time'
               ],
               'end_time' => [
                  'type' => 'time',
                  'label' => 'End Time'
               ]
            ],
            'media' => [
               'type' => 'image',
               'label' => 'Event Photo'
            ]
         ],
         'offer' => [
            'summary' => [
               'type' => 'textarea',
               'label' => 'Offer Description',
               'required' => true
            ],
            'offer' => [
               'coupon_code' => [
                  'type' => 'text',
                  'label' => 'Coupon Code'
               ],
               'redeem_online_url' => [
                  'type' => 'url',
                  'label' => 'Redemption URL'
               ],
               'terms_conditions' => [
                  'type' => 'textarea',
                  'label' => 'Terms & Conditions']
            ],
            'media' => ['type' => 'image', 'label' => 'Offer Photo']
         ]
      ];
   }
}