<?php
namespace services;

class GoogleAdsApi
{
  private $developer_token;
  private $client_id;
  private $client_secret;
  private $refresh_token;
  private $merchant_refresh_token;
  private $manager_account_id;
  private $access_token;

  private static $API_VERSION = 'v23';
  private static $TOKEN_URL = 'https://oauth2.googleapis.com/token';
  private static $ADS_BASE_URL = 'https://googleads.googleapis.com';
  private static $MERCHANT_BASE_URL = 'https://shoppingcontent.googleapis.com/content/v2.1';

  public function __construct()
  {
    $this -> developer_token     = self::get_setting( 'google_ads_developer_token' );
    $this -> client_id           = self::get_setting( 'google_ads_client_id' );
    $this -> client_secret       = self::get_setting( 'google_ads_client_secret' );
    $this -> refresh_token       = self::get_setting( 'google_ads_refresh_token' );
    $this -> merchant_refresh_token = self::get_setting( 'google_merchant_refresh_token' );
    $this -> manager_account_id  = self::get_setting( 'google_ads_manager_account_id' );
  }

  // --- Settings CRUD ---

  public static function get_setting( $key )
  {
    global $mdb;
    return $mdb -> get( 'settings', 'setting_value', [ 'setting_key' => $key ] );
  }

  public static function set_setting( $key, $value )
  {
    global $mdb;

    if ( $mdb -> count( 'settings', [ 'setting_key' => $key ] ) )
    {
      $mdb -> update( 'settings', [ 'setting_value' => $value ], [ 'setting_key' => $key ] );
    }
    else
    {
      $mdb -> insert( 'settings', [ 'setting_key' => $key, 'setting_value' => $value ] );
    }

    if ( $key === 'google_ads_last_error' )
    {
      $error_at = null;
      if ( $value !== null && trim( (string) $value ) !== '' )
      {
        $error_at = date( 'Y-m-d H:i:s' );
      }

      self::set_setting( 'google_ads_last_error_at', $error_at );
    }
  }

  // --- Konfiguracja ---

  public function is_configured()
  {
    return !empty( $this -> developer_token )
      && !empty( $this -> client_id )
      && !empty( $this -> client_secret )
      && !empty( $this -> refresh_token );
  }

  public function is_merchant_configured()
  {
    $merchant_refresh_token = trim( (string) $this -> merchant_refresh_token );
    if ( $merchant_refresh_token === '' )
    {
      $merchant_refresh_token = trim( (string) $this -> refresh_token );
    }

    return !empty( $this -> client_id )
      && !empty( $this -> client_secret )
      && $merchant_refresh_token !== '';
  }

  public function get_merchant_product_links_for_offer_ids( $merchant_account_id, $offer_ids )
  {
    $merchant_account_id = preg_replace( '/\D+/', '', (string) $merchant_account_id );
    $offer_ids = array_values( array_unique( array_filter( array_map( function( $item )
    {
      return trim( (string) $item );
    }, (array) $offer_ids ) ) ) );

    if ( $merchant_account_id === '' )
    {
      self::set_setting( 'google_merchant_last_error', 'Brak Merchant Account ID.' );
      return false;
    }

    if ( empty( $offer_ids ) )
    {
      return [];
    }

    $access_token = $this -> get_merchant_access_token();
    if ( !$access_token )
    {
      return false;
    }

    // 1. Proba bezposredniego GET po skonstruowanym productId
    $found = [];
    $remaining_ids = [];

    foreach ( $offer_ids as $oid )
    {
      $item = $this -> try_direct_merchant_product_get( $merchant_account_id, $oid, $access_token );
      if ( $item !== null )
      {
        $link = trim( (string) ( $item['link'] ?? '' ) );
        if ( $this -> is_valid_merchant_product_url( $link ) )
        {
          $found[ $oid ] = $link;
          continue;
        }
      }
      $remaining_ids[] = $oid;
    }

    if ( empty( $remaining_ids ) )
    {
      self::set_setting( 'google_merchant_last_error', null );
      return $found;
    }

    // 2. Fallback: listowanie z case-insensitive matching
    $remaining_lower = [];
    foreach ( $remaining_ids as $oid )
    {
      $remaining_lower[ strtolower( $oid ) ] = $oid;
    }

    $page_token = '';
    $safety_limit = 500;

    while ( $safety_limit-- > 0 )
    {
      $query = [ 'maxResults' => 250 ];
      if ( $page_token !== '' )
      {
        $query['pageToken'] = $page_token;
      }

      $url = self::$MERCHANT_BASE_URL . '/' . rawurlencode( $merchant_account_id ) . '/products?' . http_build_query( $query );

      $ch = curl_init( $url );
      curl_setopt_array( $ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
          'Authorization: Bearer ' . $access_token,
          'Accept: application/json'
        ],
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_TIMEOUT => 120,
      ] );

      $response = curl_exec( $ch );
      $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
      $error = curl_error( $ch );
      curl_close( $ch );

      if ( $http_code !== 200 || !$response )
      {
        self::set_setting( 'google_merchant_last_error', 'Merchant products.list failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . substr( (string) $response, 0, 1000 ) );
        return false;
      }

      $payload = json_decode( (string) $response, true );
      if ( !is_array( $payload ) )
      {
        self::set_setting( 'google_merchant_last_error', 'Merchant products.list: niepoprawna odpowiedz JSON.' );
        return false;
      }

      $items = isset( $payload['resources'] ) && is_array( $payload['resources'] )
        ? $payload['resources']
        : ( isset( $payload['items'] ) && is_array( $payload['items'] ) ? $payload['items'] : [] );

      foreach ( $items as $item )
      {
        $item_offer_id = trim( (string) ( $item['offerId'] ?? '' ) );
        if ( $item_offer_id === '' )
        {
          continue;
        }

        $item_offer_id_lower = strtolower( $item_offer_id );
        if ( !isset( $remaining_lower[ $item_offer_id_lower ] ) )
        {
          continue;
        }

        $link = trim( (string) ( $item['link'] ?? '' ) );
        if ( !$this -> is_valid_merchant_product_url( $link ) )
        {
          continue;
        }

        $original_key = $remaining_lower[ $item_offer_id_lower ];
        $found[ $original_key ] = $link;
        unset( $remaining_lower[ $item_offer_id_lower ] );
      }

      if ( empty( $remaining_lower ) )
      {
        break;
      }

      $page_token = trim( (string) ( $payload['nextPageToken'] ?? '' ) );
      if ( $page_token === '' )
      {
        break;
      }
    }

    self::set_setting( 'google_merchant_last_error', null );
    return $found;
  }

  public function get_merchant_products_for_offer_ids( $merchant_account_id, $offer_ids )
  {
    $merchant_account_id = preg_replace( '/\D+/', '', (string) $merchant_account_id );
    $offer_ids = array_values( array_unique( array_filter( array_map( function( $item )
    {
      return trim( (string) $item );
    }, (array) $offer_ids ) ) ) );

    if ( $merchant_account_id === '' )
    {
      self::set_setting( 'google_merchant_last_error', 'Brak Merchant Account ID.' );
      return false;
    }

    if ( empty( $offer_ids ) )
    {
      return [];
    }

    $access_token = $this -> get_merchant_access_token();
    if ( !$access_token )
    {
      return false;
    }

    // 1. Proba bezposredniego GET po skonstruowanym productId (szybkie, pewne)
    $found = [];
    $remaining_ids = [];

    foreach ( $offer_ids as $oid )
    {
      $item = $this -> try_direct_merchant_product_get( $merchant_account_id, $oid, $access_token );
      if ( $item !== null )
      {
        $found[ $oid ] = $item;
      }
      else
      {
        $remaining_ids[] = $oid;
      }
    }

    if ( empty( $remaining_ids ) )
    {
      self::set_setting( 'google_merchant_last_error', null );
      return $found;
    }

    // 2. Fallback: listowanie produktow z case-insensitive matching
    $remaining_lower = [];
    foreach ( $remaining_ids as $oid )
    {
      $remaining_lower[ strtolower( $oid ) ] = $oid;
    }

    $page_token = '';
    $safety_limit = 500;

    while ( $safety_limit-- > 0 )
    {
      $query = [ 'maxResults' => 250 ];
      if ( $page_token !== '' )
      {
        $query['pageToken'] = $page_token;
      }

      $url = self::$MERCHANT_BASE_URL . '/' . rawurlencode( $merchant_account_id ) . '/products?' . http_build_query( $query );

      $ch = curl_init( $url );
      curl_setopt_array( $ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
          'Authorization: Bearer ' . $access_token,
          'Accept: application/json'
        ],
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_TIMEOUT => 120,
      ] );

      $response = curl_exec( $ch );
      $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
      $error = curl_error( $ch );
      curl_close( $ch );

      if ( $http_code !== 200 || !$response )
      {
        self::set_setting( 'google_merchant_last_error', 'Merchant products.list failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . substr( (string) $response, 0, 1000 ) );
        return false;
      }

      $payload = json_decode( (string) $response, true );
      if ( !is_array( $payload ) )
      {
        self::set_setting( 'google_merchant_last_error', 'Merchant products.list: niepoprawna odpowiedz JSON.' );
        return false;
      }

      $items = isset( $payload['resources'] ) && is_array( $payload['resources'] )
        ? $payload['resources']
        : ( isset( $payload['items'] ) && is_array( $payload['items'] ) ? $payload['items'] : [] );

      foreach ( $items as $item )
      {
        $item_offer_id = trim( (string) ( $item['offerId'] ?? '' ) );
        if ( $item_offer_id === '' )
        {
          continue;
        }

        $item_offer_id_lower = strtolower( $item_offer_id );
        if ( !isset( $remaining_lower[ $item_offer_id_lower ] ) )
        {
          continue;
        }

        $original_key = $remaining_lower[ $item_offer_id_lower ];
        $found[ $original_key ] = $item;
        unset( $remaining_lower[ $item_offer_id_lower ] );
      }

      if ( empty( $remaining_lower ) )
      {
        break;
      }

      $page_token = trim( (string) ( $payload['nextPageToken'] ?? '' ) );
      if ( $page_token === '' )
      {
        break;
      }
    }

    self::set_setting( 'google_merchant_last_error', null );
    return $found;
  }

  /**
   * Bezposredni GET produktu z Merchant Center po skonstruowanym productId.
   * Format: online:{contentLanguage}:{feedLabel}:{offerId}
   * Probuje kilka wariantow lang/country wyekstrahowanych z offer_id.
   */
  private function try_direct_merchant_product_get( $merchant_account_id, $offer_id, $access_token )
  {
    $candidates = [];

    // Ekstrakcja jezyka/kraju z Shopify-style offer_id: shopify_XX_...
    if ( preg_match( '/^shopify_([a-z]{2})_/i', $offer_id, $m ) )
    {
      $lang = strtolower( $m[1] );
      $country = strtoupper( $m[1] );
      $candidates[] = 'online:' . $lang . ':' . $country . ':' . $offer_id;
    }

    // Domyslnie polski rynek
    $candidates[] = 'online:pl:PL:' . $offer_id;
    $candidates[] = 'online:en:PL:' . $offer_id;

    $candidates = array_unique( $candidates );

    foreach ( $candidates as $product_id )
    {
      $url = self::$MERCHANT_BASE_URL . '/' . rawurlencode( $merchant_account_id )
        . '/products/' . rawurlencode( $product_id );

      $ch = curl_init( $url );
      curl_setopt_array( $ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
          'Authorization: Bearer ' . $access_token,
          'Accept: application/json'
        ],
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_TIMEOUT => 30,
      ] );

      $response = curl_exec( $ch );
      $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
      curl_close( $ch );

      if ( $http_code === 200 && $response )
      {
        $item = json_decode( (string) $response, true );
        if ( is_array( $item ) && !empty( $item['id'] ) )
        {
          return $item;
        }
      }
    }

    return null;
  }

  public function update_merchant_product_fields_by_offer_id( $merchant_account_id, $offer_id, $fields )
  {
    $merchant_account_id = preg_replace( '/\D+/', '', (string) $merchant_account_id );
    $offer_id = trim( (string) $offer_id );
    $fields = is_array( $fields ) ? $fields : [];

    if ( $merchant_account_id === '' || $offer_id === '' )
    {
      self::set_setting( 'google_merchant_last_error', 'Brak Merchant Account ID lub offer_id.' );
      return [ 'success' => false, 'error' => 'Brak Merchant Account ID lub offer_id.' ];
    }

    $field_map = [
      'title' => 'title',
      'description' => 'description',
      'google_product_category' => 'googleProductCategory',
      'custom_label_0' => 'customLabel0',
      'custom_label_1' => 'customLabel1',
      'custom_label_2' => 'customLabel2',
      'custom_label_3' => 'customLabel3',
      'custom_label_4' => 'customLabel4'
    ];

    $patch_payload = [];
    foreach ( $fields as $key => $value )
    {
      if ( !isset( $field_map[ $key ] ) )
      {
        continue;
      }

      $patch_payload[ $field_map[ $key ] ] = $value;
    }

    if ( empty( $patch_payload ) )
    {
      return [ 'success' => true, 'skipped' => true, 'message' => 'Brak wspieranych pol do synchronizacji.' ];
    }

    $items_map = $this -> get_merchant_products_for_offer_ids( $merchant_account_id, [ $offer_id ] );
    if ( $items_map === false || !isset( $items_map[ $offer_id ] ) )
    {
      $error_message = trim( (string) self::get_setting( 'google_merchant_last_error' ) );
      if ( $error_message === '' )
      {
        $error_message = 'Nie znaleziono produktu w Merchant Center dla offer_id: ' . $offer_id;
      }

      return [ 'success' => false, 'error' => $error_message ];
    }

    $merchant_item = $items_map[ $offer_id ];
    $merchant_product_id = trim( (string) ( $merchant_item['id'] ?? '' ) );

    if ( $merchant_product_id === '' )
    {
      return [ 'success' => false, 'error' => 'Brak identyfikatora produktu Merchant (id) dla offer_id: ' . $offer_id ];
    }

    $access_token = $this -> get_merchant_access_token();
    if ( !$access_token )
    {
      return [ 'success' => false, 'error' => (string) self::get_setting( 'google_merchant_last_error' ) ];
    }

    $url = self::$MERCHANT_BASE_URL
      . '/' . rawurlencode( $merchant_account_id )
      . '/products/' . rawurlencode( $merchant_product_id );

    $ch = curl_init( $url );
    curl_setopt_array( $ch, [
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_CUSTOMREQUEST => 'PATCH',
      CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . $access_token,
        'Content-Type: application/json',
        'Accept: application/json'
      ],
      CURLOPT_POSTFIELDS => json_encode( $patch_payload ),
      CURLOPT_SSL_VERIFYPEER => true,
      CURLOPT_TIMEOUT => 120,
    ] );

    $response = curl_exec( $ch );
    $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
    $error = curl_error( $ch );
    curl_close( $ch );

    if ( $http_code !== 200 || !$response )
    {
      $error_data = json_decode( (string) $response, true );
      $error_message = (string) ( $error_data['error']['message'] ?? '' );
      if ( $error_message === '' )
      {
        $error_message = 'Merchant products.patch failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . substr( (string) $response, 0, 1000 );
      }

      self::set_setting( 'google_merchant_last_error', $error_message );
      return [
        'success' => false,
        'error' => $error_message,
        'merchant_product_id' => $merchant_product_id,
        'response' => $response
      ];
    }

    $response_data = json_decode( (string) $response, true );
    self::set_setting( 'google_merchant_last_error', null );

    return [
      'success' => true,
      'merchant_product_id' => $merchant_product_id,
      'response' => $response_data,
      'patched_fields' => array_keys( $patch_payload )
    ];
  }

  // --- OAuth2 ---

  private function get_access_token()
  {
    $cached_token   = self::get_setting( 'google_ads_access_token' );
    $cached_expires = (int) self::get_setting( 'google_ads_access_token_expires' );

    if ( $cached_token && $cached_expires > time() )
    {
      $this -> access_token = $cached_token;
      return $this -> access_token;
    }

    return $this -> refresh_access_token();
  }

  private function refresh_access_token()
  {
    $ch = curl_init( self::$TOKEN_URL );
    curl_setopt_array( $ch, [
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_POST           => true,
      CURLOPT_POSTFIELDS     => http_build_query( [
        'client_id'     => $this -> client_id,
        'client_secret' => $this -> client_secret,
        'refresh_token' => $this -> refresh_token,
        'grant_type'    => 'refresh_token'
      ] ),
      CURLOPT_SSL_VERIFYPEER => true,
      CURLOPT_TIMEOUT        => 30,
    ] );

    $response  = curl_exec( $ch );
    $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
    $error     = curl_error( $ch );
    curl_close( $ch );

    if ( $http_code !== 200 || !$response )
    {
      self::set_setting( 'google_ads_last_error', 'Token refresh failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . $response );
      return false;
    }

    $data = json_decode( $response, true );

    if ( !isset( $data['access_token'] ) )
    {
      self::set_setting( 'google_ads_last_error', 'Token refresh: brak access_token w odpowiedzi' );
      return false;
    }

    $this -> access_token = $data['access_token'];
    $expires_at = time() + ( $data['expires_in'] ?? 3600 ) - 60;

    self::set_setting( 'google_ads_access_token', $this -> access_token );
    self::set_setting( 'google_ads_access_token_expires', $expires_at );
    self::set_setting( 'google_ads_last_error', null );

    return $this -> access_token;
  }

  private function get_merchant_access_token()
  {
    $cached_token = self::get_setting( 'google_merchant_access_token' );
    $cached_expires = (int) self::get_setting( 'google_merchant_access_token_expires' );

    if ( $cached_token && $cached_expires > time() )
    {
      return $cached_token;
    }

    return $this -> refresh_merchant_access_token();
  }

  private function refresh_merchant_access_token()
  {
    $merchant_refresh_token = trim( (string) $this -> merchant_refresh_token );
    if ( $merchant_refresh_token === '' )
    {
      $merchant_refresh_token = trim( (string) $this -> refresh_token );
    }

    if ( $merchant_refresh_token === '' )
    {
      self::set_setting( 'google_merchant_last_error', 'Brak refresh tokena dla Merchant API.' );
      return false;
    }

    $ch = curl_init( self::$TOKEN_URL );
    curl_setopt_array( $ch, [
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_POST => true,
      CURLOPT_POSTFIELDS => http_build_query( [
        'client_id' => $this -> client_id,
        'client_secret' => $this -> client_secret,
        'refresh_token' => $merchant_refresh_token,
        'grant_type' => 'refresh_token'
      ] ),
      CURLOPT_SSL_VERIFYPEER => true,
      CURLOPT_TIMEOUT => 30,
    ] );

    $response = curl_exec( $ch );
    $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
    $error = curl_error( $ch );
    curl_close( $ch );

    if ( $http_code !== 200 || !$response )
    {
      self::set_setting( 'google_merchant_last_error', 'Merchant token refresh failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . $response );
      return false;
    }

    $data = json_decode( (string) $response, true );

    if ( !isset( $data['access_token'] ) )
    {
      self::set_setting( 'google_merchant_last_error', 'Merchant token refresh: brak access_token w odpowiedzi' );
      return false;
    }

    $access_token = (string) $data['access_token'];
    $expires_at = time() + ( $data['expires_in'] ?? 3600 ) - 60;

    self::set_setting( 'google_merchant_access_token', $access_token );
    self::set_setting( 'google_merchant_access_token_expires', $expires_at );
    self::set_setting( 'google_merchant_last_error', null );

    return $access_token;
  }

  // --- Google Ads API ---

  public function search_stream( $customer_id, $gaql_query )
  {
    $access_token = $this -> get_access_token();
    if ( !$access_token ) return false;

    $customer_id = str_replace( '-', '', $customer_id );

    $url = self::$ADS_BASE_URL . '/' . self::$API_VERSION
         . '/customers/' . $customer_id . '/googleAds:searchStream';

    $headers = [
      'Authorization: Bearer ' . $access_token,
      'developer-token: ' . $this -> developer_token,
      'Content-Type: application/json',
    ];

    if ( !empty( $this -> manager_account_id ) )
    {
      $headers[] = 'login-customer-id: ' . str_replace( '-', '', $this -> manager_account_id );
    }

    $ch = curl_init( $url );
    curl_setopt_array( $ch, [
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_POST           => true,
      CURLOPT_HTTPHEADER     => $headers,
      CURLOPT_POSTFIELDS     => json_encode( [ 'query' => $gaql_query ] ),
      CURLOPT_SSL_VERIFYPEER => true,
      CURLOPT_TIMEOUT        => 120,
    ] );

    $response  = curl_exec( $ch );
    $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
    $error     = curl_error( $ch );
    curl_close( $ch );

    if ( $http_code !== 200 || !$response )
    {
      self::set_setting( 'google_ads_last_error', 'searchStream failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . (string) $response );
      return false;
    }

    $data = json_decode( $response, true );

    // searchStream zwraca tablicę batch'y, każdy z kluczem 'results'
    $results = [];
    if ( is_array( $data ) )
    {
      foreach ( $data as $batch )
      {
        if ( isset( $batch['results'] ) && is_array( $batch['results'] ) )
        {
          $results = array_merge( $results, $batch['results'] );
        }
      }
    }

    self::set_setting( 'google_ads_last_error', null );

    return $results;
  }

  public function mutate( $customer_id, $mutate_operations, $partial_failure = false )
  {
    $access_token = $this -> get_access_token();
    if ( !$access_token ) return false;

    $customer_id = str_replace( '-', '', $customer_id );

    $url = self::$ADS_BASE_URL . '/' . self::$API_VERSION
         . '/customers/' . $customer_id . '/googleAds:mutate';

    $headers = [
      'Authorization: Bearer ' . $access_token,
      'developer-token: ' . $this -> developer_token,
      'Content-Type: application/json',
    ];

    if ( !empty( $this -> manager_account_id ) )
    {
      $headers[] = 'login-customer-id: ' . str_replace( '-', '', $this -> manager_account_id );
    }

    $payload = [
      'mutateOperations' => array_values( $mutate_operations ),
      'partialFailure' => (bool) $partial_failure
    ];

    $ch = curl_init( $url );
    curl_setopt_array( $ch, [
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_POST => true,
      CURLOPT_HTTPHEADER => $headers,
      CURLOPT_POSTFIELDS => json_encode( $payload ),
      CURLOPT_SSL_VERIFYPEER => true,
      CURLOPT_TIMEOUT => 120,
    ] );

    $response = curl_exec( $ch );
    $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
    $error = curl_error( $ch );
    curl_close( $ch );

    if ( $http_code !== 200 || !$response )
    {
      self::set_setting( 'google_ads_last_error', 'mutate failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . (string) $response );
      return false;
    }

    $data = json_decode( $response, true );
    if ( !is_array( $data ) )
    {
      self::set_setting( 'google_ads_last_error', 'mutate failed: niepoprawna odpowiedz JSON' );
      return false;
    }

    self::set_setting( 'google_ads_last_error', null );
    return $data;
  }

  private function normalize_ads_customer_id( $customer_id )
  {
    return preg_replace( '/\D+/', '', (string) $customer_id );
  }

  private function parse_resource_id( $resource_name )
  {
    $resource_name = trim( (string) $resource_name );

    if ( preg_match( '#/(\d+)$#', $resource_name, $matches ) )
    {
      return (int) $matches[1];
    }

    return 0;
  }

  public function create_standard_shopping_campaign( $customer_id, $merchant_account_id, $campaign_name, $daily_budget = 50.0, $sales_country = 'PL' )
  {
    $customer_id = $this -> normalize_ads_customer_id( $customer_id );
    $merchant_account_id = preg_replace( '/\D+/', '', (string) $merchant_account_id );
    $campaign_name = trim( (string) $campaign_name );
    $sales_country = strtoupper( trim( (string) $sales_country ) );
    $daily_budget = (float) $daily_budget;

    if ( $customer_id === '' || $merchant_account_id === '' || $campaign_name === '' )
    {
      self::set_setting( 'google_ads_last_error', 'Brak danych do utworzenia kampanii Standard Shopping.' );
      return [ 'success' => false ];
    }

    if ( $sales_country === '' )
    {
      $sales_country = 'PL';
    }

    if ( $daily_budget <= 0 )
    {
      $daily_budget = 50.0;
    }

    $budget_micros = max( 1000000, (int) round( $daily_budget * 1000000 ) );
    $budget_tmp_resource = 'customers/' . $customer_id . '/campaignBudgets/-1';

    $operations = [
      [
        'campaignBudgetOperation' => [
          'create' => [
            'resourceName' => $budget_tmp_resource,
            'name' => 'adsPRO | Budget | ' . $campaign_name . ' | ' . date( 'Y-m-d H:i:s' ),
            'amountMicros' => $budget_micros,
            'deliveryMethod' => 'STANDARD',
            'explicitlyShared' => false
          ]
        ]
      ],
      [
        'campaignOperation' => [
          'create' => [
            'name' => $campaign_name,
            'status' => 'PAUSED',
            'advertisingChannelType' => 'SHOPPING',
            'campaignBudget' => $budget_tmp_resource,
            'manualCpc' => (object) [],
            'shoppingSetting' => [
              'merchantId' => (int) $merchant_account_id,
              'salesCountry' => $sales_country,
              'campaignPriority' => 0,
              'enableLocal' => false
            ]
          ]
        ]
      ]
    ];

    $result = $this -> mutate( $customer_id, $operations );

    if ( $result === false )
    {
      return [
        'success' => false,
        'error' => (string) self::get_setting( 'google_ads_last_error' ),
        'sent_operations' => $operations
      ];
    }

    $campaign_resource_name = '';
    foreach ( (array) ( $result['mutateOperationResponses'] ?? [] ) as $row )
    {
      $campaign_resource_name = (string) ( $row['campaignResult']['resourceName'] ?? '' );
      if ( $campaign_resource_name === '' )
      {
        $campaign_resource_name = (string) ( $row['campaign_result']['resource_name'] ?? '' );
      }

      if ( $campaign_resource_name !== '' )
      {
        break;
      }
    }

    $campaign_id = $this -> parse_resource_id( $campaign_resource_name );

    if ( $campaign_id <= 0 )
    {
      self::set_setting( 'google_ads_last_error', 'Nie udało się odczytać ID kampanii z odpowiedzi mutate.' );
      return [ 'success' => false, 'response' => $result ];
    }

    return [
      'success' => true,
      'campaign_id' => $campaign_id,
      'campaign_name' => $campaign_name,
      'response' => $result
    ];
  }

  private function get_listing_groups_count_for_ad_group( $customer_id, $ad_group_id )
  {
    $customer_id = $this -> normalize_ads_customer_id( $customer_id );
    $ad_group_id = (int) $ad_group_id;

    if ( $customer_id === '' || $ad_group_id <= 0 )
    {
      return false;
    }

    $gaql = "SELECT "
          . "ad_group_criterion.resource_name "
          . "FROM ad_group_criterion "
          . "WHERE ad_group.id = " . $ad_group_id . " "
          . "AND ad_group_criterion.type = 'LISTING_GROUP' "
          . "LIMIT 100";

    $rows = $this -> search_stream( $customer_id, $gaql );
    if ( !is_array( $rows ) )
    {
      return false;
    }

    return count( $rows );
  }

  private function get_listing_group_nodes_for_ad_group( $customer_id, $ad_group_id )
  {
    $customer_id = $this -> normalize_ads_customer_id( $customer_id );
    $ad_group_id = (int) $ad_group_id;

    if ( $customer_id === '' || $ad_group_id <= 0 )
    {
      return false;
    }

    $gaql = "SELECT "
          . "ad_group_criterion.resource_name, "
          . "ad_group_criterion.listing_group.parent_ad_group_criterion "
          . "FROM ad_group_criterion "
          . "WHERE ad_group.id = " . $ad_group_id . " "
          . "AND ad_group_criterion.type = 'LISTING_GROUP' "
          . "LIMIT 1000";

    $rows = $this -> search_stream( $customer_id, $gaql );
    if ( !is_array( $rows ) )
    {
      return false;
    }

    $nodes = [];
    foreach ( $rows as $row )
    {
      $resource_name = trim( (string) ( $row['adGroupCriterion']['resourceName'] ?? $row['ad_group_criterion']['resource_name'] ?? '' ) );
      if ( $resource_name === '' )
      {
        continue;
      }

      $parent_resource = trim( (string) ( $row['adGroupCriterion']['listingGroup']['parentAdGroupCriterion'] ?? $row['ad_group_criterion']['listing_group']['parent_ad_group_criterion'] ?? '' ) );

      $nodes[] = [
        'resource_name' => $resource_name,
        'parent_resource_name' => $parent_resource
      ];
    }

    return $nodes;
  }

  private function build_listing_group_removal_order( $nodes )
  {
    $remaining = [];
    foreach ( (array) $nodes as $node )
    {
      $resource_name = trim( (string) ( $node['resource_name'] ?? '' ) );
      if ( $resource_name === '' )
      {
        continue;
      }

      $remaining[ $resource_name ] = trim( (string) ( $node['parent_resource_name'] ?? '' ) );
    }

    $order = [];

    while ( !empty( $remaining ) )
    {
      $parent_set = [];
      foreach ( $remaining as $resource_name => $parent_resource )
      {
        if ( $parent_resource !== '' && isset( $remaining[ $parent_resource ] ) )
        {
          $parent_set[ $parent_resource ] = true;
        }
      }

      $leaf_resources = [];
      foreach ( $remaining as $resource_name => $parent_resource )
      {
        if ( !isset( $parent_set[ $resource_name ] ) )
        {
          $leaf_resources[] = $resource_name;
        }
      }

      if ( empty( $leaf_resources ) )
      {
        foreach ( array_keys( $remaining ) as $resource_name )
        {
          $order[] = $resource_name;
        }
        break;
      }

      foreach ( $leaf_resources as $resource_name )
      {
        $order[] = $resource_name;
        unset( $remaining[ $resource_name ] );
      }
    }

    return array_values( array_unique( $order ) );
  }

  private function clear_listing_groups_in_ad_group( $customer_id, $ad_group_id )
  {
    $customer_id = $this -> normalize_ads_customer_id( $customer_id );
    $ad_group_id = (int) $ad_group_id;

    if ( $customer_id === '' || $ad_group_id <= 0 )
    {
      return [ 'success' => false, 'removed' => 0 ];
    }

    $nodes = $this -> get_listing_group_nodes_for_ad_group( $customer_id, $ad_group_id );
    if ( $nodes === false )
    {
      return [
        'success' => false,
        'removed' => 0,
        'error' => (string) self::get_setting( 'google_ads_last_error' )
      ];
    }

    if ( empty( $nodes ) )
    {
      return [ 'success' => true, 'removed' => 0 ];
    }

    $removal_order = $this -> build_listing_group_removal_order( $nodes );

    if ( empty( $removal_order ) )
    {
      return [ 'success' => true, 'removed' => 0 ];
    }

    $operations = [];
    foreach ( $removal_order as $resource_name )
    {
      $operations[] = [
        'adGroupCriterionOperation' => [
          'remove' => $resource_name
        ]
      ];
    }

    $result = $this -> mutate( $customer_id, $operations );
    if ( $result === false )
    {
      return [
        'success' => false,
        'removed' => 0,
        'error' => (string) self::get_setting( 'google_ads_last_error' ),
        'sent_operations_count' => count( $operations )
      ];
    }

    return [ 'success' => true, 'removed' => count( $operations ) ];
  }

  private function find_ad_group_id_by_campaign_and_name( $customer_id, $campaign_id, $ad_group_name )
  {
    $customer_id = $this -> normalize_ads_customer_id( $customer_id );
    $campaign_id = (int) $campaign_id;
    $ad_group_name = trim( (string) $ad_group_name );

    if ( $customer_id === '' || $campaign_id <= 0 || $ad_group_name === '' )
    {
      return 0;
    }

    $gaql = "SELECT "
          . "ad_group.id "
          . "FROM ad_group "
          . "WHERE campaign.id = " . $campaign_id . " "
          . "AND ad_group.name = '" . $this -> gaql_escape( $ad_group_name ) . "' "
          . "AND ad_group.status != 'REMOVED' "
          . "LIMIT 1";

    $rows = $this -> search_stream( $customer_id, $gaql );
    if ( !is_array( $rows ) || empty( $rows ) )
    {
      return 0;
    }

    return (int) ( $rows[0]['adGroup']['id'] ?? $rows[0]['ad_group']['id'] ?? 0 );
  }

  private function get_ad_group_status( $customer_id, $ad_group_id )
  {
    $customer_id = $this -> normalize_ads_customer_id( $customer_id );
    $ad_group_id = (int) $ad_group_id;

    if ( $customer_id === '' || $ad_group_id <= 0 )
    {
      return '';
    }

    $gaql = "SELECT "
          . "ad_group.status "
          . "FROM ad_group "
          . "WHERE ad_group.id = " . $ad_group_id . " "
          . "LIMIT 1";

    $rows = $this -> search_stream( $customer_id, $gaql );
    if ( !is_array( $rows ) || empty( $rows ) )
    {
      return '';
    }

    return strtoupper( trim( (string) ( $rows[0]['adGroup']['status'] ?? $rows[0]['ad_group']['status'] ?? '' ) ) );
  }

  private function enable_ad_group( $customer_id, $ad_group_id )
  {
    $customer_id = $this -> normalize_ads_customer_id( $customer_id );
    $ad_group_id = (int) $ad_group_id;

    if ( $customer_id === '' || $ad_group_id <= 0 )
    {
      return false;
    }

    $operation = [
      'adGroupOperation' => [
        'update' => [
          'resourceName' => 'customers/' . $customer_id . '/adGroups/' . $ad_group_id,
          'status' => 'ENABLED'
        ],
        'updateMask' => 'status'
      ]
    ];

    return $this -> mutate( $customer_id, [ $operation ] ) !== false;
  }

  public function remove_ad_group( $customer_id, $ad_group_id )
  {
    $customer_id = $this -> normalize_ads_customer_id( $customer_id );
    $ad_group_id = (int) $ad_group_id;

    if ( $customer_id === '' || $ad_group_id <= 0 )
    {
      self::set_setting( 'google_ads_last_error', 'Brak danych do usuniecia grupy reklam.' );
      return [ 'success' => false, 'removed' => 0 ];
    }

    $resource_name = 'customers/' . $customer_id . '/adGroups/' . $ad_group_id;
    $operation = [
      'adGroupOperation' => [
        'remove' => $resource_name
      ]
    ];

    $mutate_result = $this -> mutate( $customer_id, [ $operation ] );

    if ( $mutate_result === false )
    {
      $last_error = (string) self::get_setting( 'google_ads_last_error' );
      $is_not_found = stripos( $last_error, 'NOT_FOUND' ) !== false
        || stripos( $last_error, 'RESOURCE_NOT_FOUND' ) !== false;

      if ( $is_not_found )
      {
        return [
          'success' => true,
          'removed' => 0,
          'not_found' => true,
          'resource_name' => $resource_name
        ];
      }

      return [
        'success' => false,
        'removed' => 0,
        'error' => $last_error,
        'resource_name' => $resource_name
      ];
    }

    return [
      'success' => true,
      'removed' => 1,
      'response' => $mutate_result,
      'resource_name' => $resource_name
    ];
  }

  private function get_root_listing_group_resource_name( $customer_id, $ad_group_id )
  {
    $customer_id = $this -> normalize_ads_customer_id( $customer_id );
    $ad_group_id = (int) $ad_group_id;

    if ( $customer_id === '' || $ad_group_id <= 0 )
    {
      return '';
    }

    $gaql = "SELECT "
          . "ad_group_criterion.resource_name, "
          . "ad_group_criterion.listing_group.type "
          . "FROM ad_group_criterion "
          . "WHERE ad_group.id = " . $ad_group_id . " "
          . "AND ad_group_criterion.type = 'LISTING_GROUP' "
          . "LIMIT 50";

    $rows = $this -> search_stream( $customer_id, $gaql );
    if ( !is_array( $rows ) || empty( $rows ) )
    {
      return '';
    }

    $fallback_resource = '';

    foreach ( $rows as $row )
    {
      $type = strtoupper( (string) ( $row['listingGroup']['type'] ?? $row['listing_group']['type'] ?? '' ) );
      $resource_name = trim( (string) ( $row['adGroupCriterion']['resourceName'] ?? $row['ad_group_criterion']['resource_name'] ?? '' ) );

      if ( $resource_name === '' )
      {
        continue;
      }

      if ( $fallback_resource === '' )
      {
        $fallback_resource = $resource_name;
      }

      if ( $type === 'SUBDIVISION' )
      {
        return $resource_name;
      }
    }

    return $fallback_resource;
  }

  private function extract_first_ad_group_criterion_resource_name_from_mutate( $mutate_response )
  {
    foreach ( (array) ( $mutate_response['mutateOperationResponses'] ?? [] ) as $row )
    {
      $resource_name = trim( (string) ( $row['adGroupCriterionResult']['resourceName'] ?? '' ) );
      if ( $resource_name === '' )
      {
        $resource_name = trim( (string) ( $row['ad_group_criterion_result']['resource_name'] ?? '' ) );
      }

      if ( $resource_name !== '' )
      {
        return $resource_name;
      }
    }

    return '';
  }

  public function ensure_standard_shopping_offer_in_ad_group( $customer_id, $ad_group_id, $offer_id, $cpc_bid = 1.00 )
  {
    $customer_id = $this -> normalize_ads_customer_id( $customer_id );
    $ad_group_id = (int) $ad_group_id;
    $offer_id = trim( (string) $offer_id );
    $cpc_bid = (float) $cpc_bid;

    if ( $customer_id === '' || $ad_group_id <= 0 || $offer_id === '' )
    {
      self::set_setting( 'google_ads_last_error', 'Brak danych do przypisania produktu do grupy reklam Standard Shopping.' );
      return [ 'success' => false ];
    }

    if ( $cpc_bid <= 0 )
    {
      $cpc_bid = 1.00;
    }

    $listing_groups_count = $this -> get_listing_groups_count_for_ad_group( $customer_id, $ad_group_id );
    if ( $listing_groups_count === false )
    {
      return [ 'success' => false, 'error' => (string) self::get_setting( 'google_ads_last_error' ) ];
    }

    if ( $listing_groups_count > 0 )
    {
      $clear_result = $this -> clear_listing_groups_in_ad_group( $customer_id, $ad_group_id );

      if ( empty( $clear_result['success'] ) )
      {
        return [
          'success' => false,
          'error' => 'Nie udało się usunąć istniejącej struktury listing groups: ' . (string) ( $clear_result['error'] ?? '' ),
          'listing_groups_count' => $listing_groups_count,
          'clear_result' => $clear_result
        ];
      }

      $listing_groups_count_after_clear = $this -> get_listing_groups_count_for_ad_group( $customer_id, $ad_group_id );
      if ( $listing_groups_count_after_clear === false )
      {
        return [ 'success' => false, 'error' => (string) self::get_setting( 'google_ads_last_error' ) ];
      }

      if ( $listing_groups_count_after_clear > 0 )
      {
        return [
          'success' => false,
          'error' => 'Po czyszczeniu nadal istnieją listing groups w grupie reklam.',
          'listing_groups_count' => $listing_groups_count_after_clear,
          'clear_result' => $clear_result
        ];
      }
    }

    $ad_group_resource = 'customers/' . $customer_id . '/adGroups/' . $ad_group_id;
    $root_temp_resource = 'customers/' . $customer_id . '/adGroupCriteria/' . $ad_group_id . '~-1';
    $cpc_bid_micros = max( 10000, (int) round( $cpc_bid * 1000000 ) );

    $operations = [
      [
        'adGroupCriterionOperation' => [
          'create' => [
            'resourceName' => $root_temp_resource,
            'adGroup' => $ad_group_resource,
            'status' => 'ENABLED',
            'listingGroup' => [
              'type' => 'SUBDIVISION'
            ]
          ]
        ]
      ],
      [
        'adGroupCriterionOperation' => [
          'create' => [
            'adGroup' => $ad_group_resource,
            'status' => 'ENABLED',
            'cpcBidMicros' => $cpc_bid_micros,
            'listingGroup' => [
              'type' => 'UNIT',
              'parentAdGroupCriterion' => $root_temp_resource,
              'caseValue' => [
                'productItemId' => [
                  'value' => $offer_id
                ]
              ]
            ]
          ]
        ]
      ],
      [
        'adGroupCriterionOperation' => [
          'create' => [
            'adGroup' => $ad_group_resource,
            'status' => 'ENABLED',
            'negative' => true,
            'listingGroup' => [
              'type' => 'UNIT',
              'parentAdGroupCriterion' => $root_temp_resource,
              'caseValue' => [
                'productItemId' => (object) []
              ]
            ]
          ]
        ]
      ]
    ];

    $result = $this -> mutate( $customer_id, $operations );

    if ( $result === false )
    {
      return [
        'success' => false,
        'error' => (string) self::get_setting( 'google_ads_last_error' ),
        'sent_operations' => $operations,
        'root_temp_resource' => $root_temp_resource
      ];
    }

    return [
      'success' => true,
      'response' => $result,
      'root_temp_resource' => $root_temp_resource
    ];
  }

  public function create_standard_shopping_ad_group_with_offer( $customer_id, $campaign_id, $ad_group_name, $offer_id, $cpc_bid = 1.00 )
  {
    $customer_id = $this -> normalize_ads_customer_id( $customer_id );
    $campaign_id = (int) $campaign_id;
    $ad_group_name = trim( (string) $ad_group_name );
    $offer_id = trim( (string) $offer_id );
    $cpc_bid = (float) $cpc_bid;

    if ( $customer_id === '' || $campaign_id <= 0 || $ad_group_name === '' || $offer_id === '' )
    {
      self::set_setting( 'google_ads_last_error', 'Brak danych do utworzenia grupy reklam Standard Shopping.' );
      return [ 'success' => false ];
    }

    if ( $cpc_bid <= 0 )
    {
      $cpc_bid = 1.00;
    }

    $ad_group_tmp_resource = 'customers/' . $customer_id . '/adGroups/-1';
    $cpc_bid_micros = max( 10000, (int) round( $cpc_bid * 1000000 ) );

    $operations = [
      [
        'adGroupOperation' => [
          'create' => [
            'resourceName' => $ad_group_tmp_resource,
            'campaign' => 'customers/' . $customer_id . '/campaigns/' . $campaign_id,
            'name' => $ad_group_name,
            'status' => 'PAUSED',
            'type' => 'SHOPPING_PRODUCT_ADS',
            'cpcBidMicros' => $cpc_bid_micros
          ]
        ]
      ],
      [
        'adGroupAdOperation' => [
          'create' => [
            'adGroup' => $ad_group_tmp_resource,
            'status' => 'PAUSED',
            'ad' => [
              'shoppingProductAd' => (object) []
            ]
          ]
        ]
      ]
    ];

    $result = $this -> mutate( $customer_id, $operations );

    if ( $result === false )
    {
      $last_error = (string) self::get_setting( 'google_ads_last_error' );
      $is_duplicate_name = stripos( $last_error, 'DUPLICATE_ADGROUP_NAME' ) !== false
        || stripos( $last_error, 'AdGroup with the same name already exists' ) !== false;

      if ( $is_duplicate_name )
      {
        $existing_ad_group_id = $this -> find_ad_group_id_by_campaign_and_name( $customer_id, $campaign_id, $ad_group_name );

        if ( $existing_ad_group_id > 0 )
        {
          $existing_status = $this -> get_ad_group_status( $customer_id, $existing_ad_group_id );

          if ( $existing_status === 'REMOVED' )
          {
            return [
              'success' => false,
              'duplicate_name' => true,
              'ad_group_id' => $existing_ad_group_id,
              'ad_group_name' => $ad_group_name,
              'error' => 'Istniejąca grupa reklam o tej nazwie jest usunięta (REMOVED). Wybierz inną nazwę grupy.'
            ];
          }

          if ( $existing_status === 'PAUSED' )
          {
            if ( !$this -> enable_ad_group( $customer_id, $existing_ad_group_id ) )
            {
              return [
                'success' => false,
                'duplicate_name' => true,
                'ad_group_id' => $existing_ad_group_id,
                'ad_group_name' => $ad_group_name,
                'error' => 'Nie udało się włączyć istniejącej grupy reklam (PAUSED -> ENABLED).'
              ];
            }
          }

          $offer_result = $this -> ensure_standard_shopping_offer_in_ad_group( $customer_id, $existing_ad_group_id, $offer_id, $cpc_bid );

          if ( !empty( $offer_result['success'] ) )
          {
            return [
              'success' => true,
              'duplicate_name' => true,
              'ad_group_id' => $existing_ad_group_id,
              'ad_group_name' => $ad_group_name,
              'offer_result' => $offer_result,
              'response' => null
            ];
          }

          return [
            'success' => false,
            'duplicate_name' => true,
            'ad_group_id' => $existing_ad_group_id,
            'ad_group_name' => $ad_group_name,
            'error' => (string) ( $offer_result['error'] ?? 'Nie udało się przypisać produktu do istniejącej grupy reklam.' ),
            'offer_result' => $offer_result,
            'response' => null
          ];
        }
      }

      return [
        'success' => false,
        'error' => $last_error,
        'sent_operations' => $operations
      ];
    }

    $ad_group_resource_name = '';
    foreach ( (array) ( $result['mutateOperationResponses'] ?? [] ) as $row )
    {
      $ad_group_resource_name = (string) ( $row['adGroupResult']['resourceName'] ?? '' );
      if ( $ad_group_resource_name === '' )
      {
        $ad_group_resource_name = (string) ( $row['ad_group_result']['resource_name'] ?? '' );
      }

      if ( $ad_group_resource_name !== '' )
      {
        break;
      }
    }

    $ad_group_id = $this -> parse_resource_id( $ad_group_resource_name );
    if ( $ad_group_id <= 0 )
    {
      self::set_setting( 'google_ads_last_error', 'Nie udało się odczytać ID grupy reklam z odpowiedzi mutate.' );
      return [ 'success' => false, 'response' => $result ];
    }

    $offer_result = $this -> ensure_standard_shopping_offer_in_ad_group( $customer_id, $ad_group_id, $offer_id, $cpc_bid );
    if ( empty( $offer_result['success'] ) )
    {
      return [
        'success' => false,
        'ad_group_id' => $ad_group_id,
        'error' => (string) ( $offer_result['error'] ?? 'Nie udało się ustawić filtra produktu w grupie reklam.' ),
        'offer_result' => $offer_result,
        'response' => $result
      ];
    }

    return [
      'success' => true,
      'ad_group_id' => $ad_group_id,
      'ad_group_name' => $ad_group_name,
      'response' => $result,
      'offer_result' => $offer_result
    ];
  }

  public function generate_keyword_ideas_from_url( $customer_id, $url, $limit = 40 )
  {
    $access_token = $this -> get_access_token();
    if ( !$access_token )
    {
      return false;
    }

    $customer_id = preg_replace( '/\D+/', '', (string) $customer_id );
    $url = trim( (string) $url );
    $limit = max( 1, (int) $limit );

    if ( $customer_id === '' || $url === '' )
    {
      self::set_setting( 'google_ads_last_error', 'Brak customer_id lub URL dla generate_keyword_ideas_from_url.' );
      return false;
    }

    if ( !filter_var( $url, FILTER_VALIDATE_URL ) )
    {
      self::set_setting( 'google_ads_last_error', 'Nieprawidlowy URL dla Keyword Planner.' );
      return false;
    }

    $endpoint = self::$ADS_BASE_URL . '/' . self::$API_VERSION . '/customers/' . $customer_id . ':generateKeywordIdeas';

    $headers = [
      'Authorization: Bearer ' . $access_token,
      'developer-token: ' . $this -> developer_token,
      'Content-Type: application/json',
    ];

    if ( !empty( $this -> manager_account_id ) )
    {
      $headers[] = 'login-customer-id: ' . str_replace( '-', '', $this -> manager_account_id );
    }

    $payload = [
      'urlSeed' => [
        'url' => $url
      ],
      'keywordPlanNetwork' => 'GOOGLE_SEARCH_AND_PARTNERS',
      'includeAdultKeywords' => false
    ];

    $ch = curl_init( $endpoint );
    curl_setopt_array( $ch, [
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_POST => true,
      CURLOPT_HTTPHEADER => $headers,
      CURLOPT_POSTFIELDS => json_encode( $payload ),
      CURLOPT_SSL_VERIFYPEER => true,
      CURLOPT_TIMEOUT => 120,
    ] );

    $response = curl_exec( $ch );
    $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
    $error = curl_error( $ch );
    curl_close( $ch );

    if ( $http_code !== 200 || !$response )
    {
      self::set_setting( 'google_ads_last_error', 'generateKeywordIdeas failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . (string) $response );
      return false;
    }

    $data = json_decode( (string) $response, true );
    if ( !is_array( $data ) )
    {
      self::set_setting( 'google_ads_last_error', 'generateKeywordIdeas failed: niepoprawna odpowiedz JSON' );
      return false;
    }

    $results = isset( $data['results'] ) && is_array( $data['results'] ) ? $data['results'] : [];
    $parsed = [];

    foreach ( $results as $row )
    {
      $keyword_text = trim( (string) ( $row['text'] ?? '' ) );
      if ( $keyword_text === '' )
      {
        continue;
      }

      $metrics = isset( $row['keywordIdeaMetrics'] ) && is_array( $row['keywordIdeaMetrics'] )
        ? $row['keywordIdeaMetrics']
        : [];

      $parsed[] = [
        'keyword' => $keyword_text,
        'avg_monthly_searches' => (int) ( $metrics['avgMonthlySearches'] ?? 0 ),
        'competition' => isset( $metrics['competition'] ) ? (string) $metrics['competition'] : null,
        'competition_index' => isset( $metrics['competitionIndex'] ) ? (int) $metrics['competitionIndex'] : null,
      ];
    }

    usort( $parsed, static function( $left, $right )
    {
      $left_volume = (int) ( $left['avg_monthly_searches'] ?? 0 );
      $right_volume = (int) ( $right['avg_monthly_searches'] ?? 0 );

      if ( $left_volume === $right_volume )
      {
        return strcasecmp( (string) ( $left['keyword'] ?? '' ), (string) ( $right['keyword'] ?? '' ) );
      }

      return $right_volume <=> $left_volume;
    } );

    $unique = [];
    foreach ( $parsed as $row )
    {
      $key = mb_strtolower( (string) $row['keyword'], 'UTF-8' );
      if ( isset( $unique[ $key ] ) )
      {
        continue;
      }

      $unique[ $key ] = $row;
      if ( count( $unique ) >= $limit )
      {
        break;
      }
    }

    self::set_setting( 'google_ads_last_error', null );
    return array_values( $unique );
  }

  public function add_negative_keyword_to_ad_group( $customer_id, $ad_group_id, $keyword_text, $match_type = 'PHRASE' )
  {
    $customer_id = trim( str_replace( '-', '', (string) $customer_id ) );
    $ad_group_id = trim( (string) $ad_group_id );
    $keyword_text = trim( (string) $keyword_text );
    $match_type = strtoupper( trim( (string) $match_type ) );

    if ( $customer_id === '' || $ad_group_id === '' || $keyword_text === '' )
    {
      self::set_setting( 'google_ads_last_error', 'Brak wymaganych danych do dodania frazy wykluczajacej.' );
      return [ 'success' => false, 'duplicate' => false ];
    }

    if ( !in_array( $match_type, [ 'PHRASE', 'EXACT', 'BROAD' ], true ) )
    {
      $match_type = 'PHRASE';
    }

    $operation = [
      'adGroupCriterionOperation' => [
        'create' => [
          'adGroup' => 'customers/' . $customer_id . '/adGroups/' . $ad_group_id,
          'negative' => true,
          'keyword' => [
            'text' => $keyword_text,
            'matchType' => $match_type
          ]
        ]
      ]
    ];

    $result = $this -> mutate( $customer_id, [ $operation ] );

    if ( $result === false )
    {
      $last_error = (string) self::get_setting( 'google_ads_last_error' );
      $is_duplicate = stripos( $last_error, 'DUPLICATE' ) !== false
        || stripos( $last_error, 'already exists' ) !== false;

      if ( $is_duplicate )
      {
        return [
          'success' => true,
          'duplicate' => true,
          'verification' => $this -> verify_negative_keyword_exists( $customer_id, 'ad_group', $keyword_text, $match_type, null, $ad_group_id )
        ];
      }

      return [ 'success' => false, 'duplicate' => false, 'sent_operation' => $operation ];
    }

    return [
      'success' => true,
      'duplicate' => false,
      'response' => $result,
      'sent_operation' => $operation,
      'verification' => $this -> verify_negative_keyword_exists( $customer_id, 'ad_group', $keyword_text, $match_type, null, $ad_group_id )
    ];
  }

  public function add_negative_keyword_to_campaign( $customer_id, $campaign_id, $keyword_text, $match_type = 'PHRASE' )
  {
    $customer_id = trim( str_replace( '-', '', (string) $customer_id ) );
    $campaign_id = trim( (string) $campaign_id );
    $keyword_text = trim( (string) $keyword_text );
    $match_type = strtoupper( trim( (string) $match_type ) );

    if ( $customer_id === '' || $campaign_id === '' || $keyword_text === '' )
    {
      self::set_setting( 'google_ads_last_error', 'Brak wymaganych danych do dodania frazy wykluczajacej.' );
      return [ 'success' => false, 'duplicate' => false ];
    }

    if ( !in_array( $match_type, [ 'PHRASE', 'EXACT', 'BROAD' ], true ) )
    {
      $match_type = 'PHRASE';
    }

    $operation = [
      'campaignCriterionOperation' => [
        'create' => [
          'campaign' => 'customers/' . $customer_id . '/campaigns/' . $campaign_id,
          'negative' => true,
          'keyword' => [
            'text' => $keyword_text,
            'matchType' => $match_type
          ]
        ]
      ]
    ];

    $result = $this -> mutate( $customer_id, [ $operation ] );

    if ( $result === false )
    {
      $last_error = (string) self::get_setting( 'google_ads_last_error' );
      $is_duplicate = stripos( $last_error, 'DUPLICATE' ) !== false
        || stripos( $last_error, 'already exists' ) !== false;

      if ( $is_duplicate )
      {
        return [
          'success' => true,
          'duplicate' => true,
          'verification' => $this -> verify_negative_keyword_exists( $customer_id, 'campaign', $keyword_text, $match_type, $campaign_id, null )
        ];
      }

      return [ 'success' => false, 'duplicate' => false, 'sent_operation' => $operation ];
    }

    return [
      'success' => true,
      'duplicate' => false,
      'response' => $result,
      'sent_operation' => $operation,
      'verification' => $this -> verify_negative_keyword_exists( $customer_id, 'campaign', $keyword_text, $match_type, $campaign_id, null )
    ];
  }

  public function remove_negative_keyword_from_campaign( $customer_id, $campaign_id, $keyword_text, $match_type = 'PHRASE' )
  {
    $customer_id = trim( str_replace( '-', '', (string) $customer_id ) );
    $campaign_id = trim( (string) $campaign_id );
    $keyword_text = trim( (string) $keyword_text );
    $match_type = strtoupper( trim( (string) $match_type ) );

    if ( $customer_id === '' || $campaign_id === '' || $keyword_text === '' )
    {
      self::set_setting( 'google_ads_last_error', 'Brak wymaganych danych do usuniecia frazy wykluczajacej.' );
      return [ 'success' => false, 'removed' => 0 ];
    }

    if ( !in_array( $match_type, [ 'PHRASE', 'EXACT', 'BROAD' ], true ) )
    {
      $match_type = 'PHRASE';
    }

    $keyword_text_escaped = $this -> gaql_escape( $keyword_text );
    $gaql = "SELECT "
          . "campaign_criterion.resource_name "
          . "FROM campaign_criterion "
          . "WHERE campaign.id = " . $campaign_id . " "
          . "AND campaign_criterion.type = 'KEYWORD' "
          . "AND campaign_criterion.negative = TRUE "
          . "AND campaign_criterion.keyword.text = '" . $keyword_text_escaped . "' "
          . "AND campaign_criterion.keyword.match_type = " . $match_type . " "
          . "LIMIT 50";

    $results = $this -> search_stream( $customer_id, $gaql );
    if ( $results === false )
    {
      return [ 'success' => false, 'removed' => 0 ];
    }

    $resource_names = [];
    foreach ( (array) $results as $row )
    {
      $resource_name = (string) ( $row['campaignCriterion']['resourceName'] ?? '' );
      if ( $resource_name === '' )
      {
        $resource_name = (string) ( $row['campaign_criterion']['resource_name'] ?? '' );
      }
      if ( $resource_name !== '' )
      {
        $resource_names[] = $resource_name;
      }
    }
    $resource_names = array_values( array_unique( $resource_names ) );

    if ( empty( $resource_names ) )
    {
      return [ 'success' => true, 'removed' => 0, 'not_found' => true ];
    }

    $operations = [];
    foreach ( $resource_names as $resource_name )
    {
      $operations[] = [
        'campaignCriterionOperation' => [
          'remove' => $resource_name
        ]
      ];
    }

    $mutate_result = $this -> mutate( $customer_id, $operations );
    if ( $mutate_result === false )
    {
      return [ 'success' => false, 'removed' => 0, 'sent_operations' => $operations ];
    }

    return [
      'success' => true,
      'removed' => count( $resource_names ),
      'response' => $mutate_result,
      'sent_operations' => $operations
    ];
  }

  public function remove_negative_keyword_from_ad_group( $customer_id, $ad_group_id, $keyword_text, $match_type = 'PHRASE' )
  {
    $customer_id = trim( str_replace( '-', '', (string) $customer_id ) );
    $ad_group_id = trim( (string) $ad_group_id );
    $keyword_text = trim( (string) $keyword_text );
    $match_type = strtoupper( trim( (string) $match_type ) );

    if ( $customer_id === '' || $ad_group_id === '' || $keyword_text === '' )
    {
      self::set_setting( 'google_ads_last_error', 'Brak wymaganych danych do usuniecia frazy wykluczajacej.' );
      return [ 'success' => false, 'removed' => 0 ];
    }

    if ( !in_array( $match_type, [ 'PHRASE', 'EXACT', 'BROAD' ], true ) )
    {
      $match_type = 'PHRASE';
    }

    $keyword_text_escaped = $this -> gaql_escape( $keyword_text );
    $gaql = "SELECT "
          . "ad_group_criterion.resource_name "
          . "FROM ad_group_criterion "
          . "WHERE ad_group.id = " . $ad_group_id . " "
          . "AND ad_group_criterion.type = 'KEYWORD' "
          . "AND ad_group_criterion.negative = TRUE "
          . "AND ad_group_criterion.keyword.text = '" . $keyword_text_escaped . "' "
          . "AND ad_group_criterion.keyword.match_type = " . $match_type . " "
          . "LIMIT 50";

    $results = $this -> search_stream( $customer_id, $gaql );
    if ( $results === false )
    {
      return [ 'success' => false, 'removed' => 0 ];
    }

    $resource_names = [];
    foreach ( (array) $results as $row )
    {
      $resource_name = (string) ( $row['adGroupCriterion']['resourceName'] ?? '' );
      if ( $resource_name === '' )
      {
        $resource_name = (string) ( $row['ad_group_criterion']['resource_name'] ?? '' );
      }
      if ( $resource_name !== '' )
      {
        $resource_names[] = $resource_name;
      }
    }
    $resource_names = array_values( array_unique( $resource_names ) );

    if ( empty( $resource_names ) )
    {
      return [ 'success' => true, 'removed' => 0, 'not_found' => true ];
    }

    $operations = [];
    foreach ( $resource_names as $resource_name )
    {
      $operations[] = [
        'adGroupCriterionOperation' => [
          'remove' => $resource_name
        ]
      ];
    }

    $mutate_result = $this -> mutate( $customer_id, $operations );
    if ( $mutate_result === false )
    {
      return [ 'success' => false, 'removed' => 0, 'sent_operations' => $operations ];
    }

    return [
      'success' => true,
      'removed' => count( $resource_names ),
      'response' => $mutate_result,
      'sent_operations' => $operations
    ];
  }

  private function gaql_escape( $value )
  {
    return str_replace( [ '\\', '\'' ], [ '\\\\', '\\\'' ], (string) $value );
  }

  public function add_keyword_to_ad_group( $customer_id, $ad_group_id, $keyword_text, $match_type = 'BROAD' )
  {
    $customer_id = trim( str_replace( '-', '', (string) $customer_id ) );
    $ad_group_id = trim( (string) $ad_group_id );
    $keyword_text = trim( (string) $keyword_text );
    $match_type = strtoupper( trim( (string) $match_type ) );

    if ( $customer_id === '' || $ad_group_id === '' || $keyword_text === '' )
    {
      self::set_setting( 'google_ads_last_error', 'Brak wymaganych danych do dodania frazy.' );
      return [ 'success' => false, 'duplicate' => false ];
    }

    if ( !in_array( $match_type, [ 'PHRASE', 'EXACT', 'BROAD' ], true ) )
    {
      $match_type = 'BROAD';
    }

    $operation = [
      'adGroupCriterionOperation' => [
        'create' => [
          'adGroup' => 'customers/' . $customer_id . '/adGroups/' . $ad_group_id,
          'keyword' => [
            'text' => $keyword_text,
            'matchType' => $match_type
          ]
        ]
      ]
    ];

    $result = $this -> mutate( $customer_id, [ $operation ] );

    if ( $result === false )
    {
      $last_error = (string) self::get_setting( 'google_ads_last_error' );
      $is_duplicate = stripos( $last_error, 'DUPLICATE' ) !== false
        || stripos( $last_error, 'already exists' ) !== false;

      if ( $is_duplicate )
      {
        return [ 'success' => true, 'duplicate' => true ];
      }

      return [ 'success' => false, 'duplicate' => false, 'sent_operation' => $operation ];
    }

    return [
      'success' => true,
      'duplicate' => false,
      'response' => $result,
      'sent_operation' => $operation
    ];
  }

  public function remove_keyword_from_ad_group( $customer_id, $ad_group_id, $keyword_text, $match_type = 'BROAD' )
  {
    $customer_id = trim( str_replace( '-', '', (string) $customer_id ) );
    $ad_group_id = trim( (string) $ad_group_id );
    $keyword_text = trim( (string) $keyword_text );
    $match_type = strtoupper( trim( (string) $match_type ) );

    if ( $customer_id === '' || $ad_group_id === '' || $keyword_text === '' )
    {
      self::set_setting( 'google_ads_last_error', 'Brak wymaganych danych do usuniecia frazy.' );
      return [ 'success' => false, 'removed' => 0 ];
    }

    if ( !in_array( $match_type, [ 'PHRASE', 'EXACT', 'BROAD' ], true ) )
    {
      $match_type = 'BROAD';
    }

    $keyword_text_escaped = $this -> gaql_escape( $keyword_text );
    $gaql = "SELECT "
          . "ad_group_criterion.resource_name "
          . "FROM ad_group_criterion "
          . "WHERE ad_group.id = " . $ad_group_id . " "
          . "AND ad_group_criterion.type = 'KEYWORD' "
          . "AND ad_group_criterion.negative = FALSE "
          . "AND ad_group_criterion.keyword.text = '" . $keyword_text_escaped . "' "
          . "AND ad_group_criterion.keyword.match_type = " . $match_type . " "
          . "LIMIT 50";

    $results = $this -> search_stream( $customer_id, $gaql );
    if ( $results === false )
    {
      return [ 'success' => false, 'removed' => 0 ];
    }

    $resource_names = [];
    foreach ( (array) $results as $row )
    {
      $resource_name = (string) ( $row['adGroupCriterion']['resourceName'] ?? '' );
      if ( $resource_name === '' )
      {
        $resource_name = (string) ( $row['ad_group_criterion']['resource_name'] ?? '' );
      }
      if ( $resource_name !== '' )
      {
        $resource_names[] = $resource_name;
      }
    }
    $resource_names = array_values( array_unique( $resource_names ) );

    if ( empty( $resource_names ) )
    {
      return [ 'success' => true, 'removed' => 0, 'not_found' => true ];
    }

    $operations = [];
    foreach ( $resource_names as $resource_name )
    {
      $operations[] = [
        'adGroupCriterionOperation' => [
          'remove' => $resource_name
        ]
      ];
    }

    $mutate_result = $this -> mutate( $customer_id, $operations );
    if ( $mutate_result === false )
    {
      return [ 'success' => false, 'removed' => 0, 'sent_operations' => $operations ];
    }

    return [
      'success' => true,
      'removed' => count( $resource_names ),
      'response' => $mutate_result,
      'sent_operations' => $operations
    ];
  }

  public function update_keyword_match_type( $customer_id, $ad_group_id, $keyword_text, $old_match_type, $new_match_type )
  {
    $remove_result = $this -> remove_keyword_from_ad_group( $customer_id, $ad_group_id, $keyword_text, $old_match_type );

    if ( !( $remove_result['success'] ?? false ) && !( $remove_result['not_found'] ?? false ) )
    {
      return [ 'success' => false, 'step' => 'remove', 'remove_result' => $remove_result ];
    }

    $add_result = $this -> add_keyword_to_ad_group( $customer_id, $ad_group_id, $keyword_text, $new_match_type );

    if ( !( $add_result['success'] ?? false ) )
    {
      return [ 'success' => false, 'step' => 'add', 'add_result' => $add_result, 'remove_result' => $remove_result ];
    }

    return [
      'success' => true,
      'remove_result' => $remove_result,
      'add_result' => $add_result
    ];
  }

  /**
   * Zmienia status keywordu (ENABLED / PAUSED) w Google Ads.
   */
  public function update_keyword_status( $customer_id, $ad_group_id, $keyword_text, $match_type, $new_status )
  {
    $customer_id = trim( str_replace( '-', '', (string) $customer_id ) );
    $ad_group_id = trim( (string) $ad_group_id );
    $keyword_text = trim( (string) $keyword_text );
    $match_type = strtoupper( trim( (string) $match_type ) );
    $new_status = strtoupper( trim( (string) $new_status ) );

    if ( $customer_id === '' || $ad_group_id === '' || $keyword_text === '' )
    {
      self::set_setting( 'google_ads_last_error', 'Brak wymaganych danych do zmiany statusu frazy.' );
      return [ 'success' => false ];
    }

    if ( !in_array( $new_status, [ 'ENABLED', 'PAUSED' ], true ) )
    {
      self::set_setting( 'google_ads_last_error', 'Nieprawidlowy status: ' . $new_status );
      return [ 'success' => false ];
    }

    if ( !in_array( $match_type, [ 'PHRASE', 'EXACT', 'BROAD' ], true ) )
    {
      $match_type = 'BROAD';
    }

    $keyword_text_escaped = $this -> gaql_escape( $keyword_text );
    $gaql = "SELECT "
          . "ad_group_criterion.resource_name "
          . "FROM ad_group_criterion "
          . "WHERE ad_group.id = " . $ad_group_id . " "
          . "AND ad_group_criterion.type = 'KEYWORD' "
          . "AND ad_group_criterion.negative = FALSE "
          . "AND ad_group_criterion.keyword.text = '" . $keyword_text_escaped . "' "
          . "AND ad_group_criterion.keyword.match_type = " . $match_type . " "
          . "LIMIT 1";

    $results = $this -> search_stream( $customer_id, $gaql );
    if ( $results === false )
    {
      return [ 'success' => false ];
    }

    $resource_name = '';
    foreach ( (array) $results as $row )
    {
      $resource_name = (string) ( $row['adGroupCriterion']['resourceName'] ?? '' );
      if ( $resource_name === '' )
      {
        $resource_name = (string) ( $row['ad_group_criterion']['resource_name'] ?? '' );
      }
      if ( $resource_name !== '' ) break;
    }

    if ( $resource_name === '' )
    {
      self::set_setting( 'google_ads_last_error', 'Nie znaleziono frazy w Google Ads.' );
      return [ 'success' => false, 'not_found' => true ];
    }

    $operation = [
      'adGroupCriterionOperation' => [
        'updateMask' => 'status',
        'update' => [
          'resourceName' => $resource_name,
          'status' => $new_status
        ]
      ]
    ];

    $mutate_result = $this -> mutate( $customer_id, [ $operation ] );
    if ( $mutate_result === false )
    {
      return [ 'success' => false, 'sent_operation' => $operation ];
    }

    return [
      'success' => true,
      'new_status' => $new_status,
      'response' => $mutate_result,
      'sent_operation' => $operation
    ];
  }

  private function verify_negative_keyword_exists( $customer_id, $scope, $keyword_text, $match_type, $campaign_id = null, $ad_group_id = null )
  {
    $customer_id = trim( str_replace( '-', '', (string) $customer_id ) );
    $scope = $scope === 'campaign' ? 'campaign' : 'ad_group';
    $match_type = strtoupper( trim( (string) $match_type ) );
    $keyword_text_escaped = $this -> gaql_escape( trim( (string) $keyword_text ) );

    if ( $scope === 'campaign' )
    {
      $campaign_id = trim( (string) $campaign_id );
      if ( $campaign_id === '' || $keyword_text_escaped === '' )
      {
        return [ 'found' => false, 'scope' => $scope, 'rows' => [], 'error' => 'Brak danych do weryfikacji.' ];
      }

      $gaql = "SELECT "
            . "campaign_criterion.resource_name, "
            . "campaign.id, "
            . "campaign_criterion.keyword.text, "
            . "campaign_criterion.keyword.match_type "
            . "FROM campaign_criterion "
            . "WHERE campaign.id = " . $campaign_id . " "
            . "AND campaign_criterion.type = 'KEYWORD' "
            . "AND campaign_criterion.negative = TRUE "
            . "AND campaign_criterion.keyword.text = '" . $keyword_text_escaped . "' "
            . "AND campaign_criterion.keyword.match_type = " . $match_type . " "
            . "LIMIT 5";
    }
    else
    {
      $ad_group_id = trim( (string) $ad_group_id );
      if ( $ad_group_id === '' || $keyword_text_escaped === '' )
      {
        return [ 'found' => false, 'scope' => $scope, 'rows' => [], 'error' => 'Brak danych do weryfikacji.' ];
      }

      $gaql = "SELECT "
            . "ad_group_criterion.resource_name, "
            . "campaign.id, "
            . "ad_group.id, "
            . "ad_group_criterion.keyword.text, "
            . "ad_group_criterion.keyword.match_type "
            . "FROM ad_group_criterion "
            . "WHERE ad_group.id = " . $ad_group_id . " "
            . "AND ad_group_criterion.type = 'KEYWORD' "
            . "AND ad_group_criterion.negative = TRUE "
            . "AND ad_group_criterion.keyword.text = '" . $keyword_text_escaped . "' "
            . "AND ad_group_criterion.keyword.match_type = " . $match_type . " "
            . "LIMIT 5";
    }

    $rows = [];
    $last_error = null;
    for ( $i = 0; $i < 3; $i++ )
    {
      $result = $this -> search_stream( $customer_id, $gaql );
      if ( is_array( $result ) )
      {
        $rows = $result;
        if ( count( $rows ) > 0 )
        {
          return [ 'found' => true, 'scope' => $scope, 'rows' => $rows ];
        }
      }
      else
      {
        $last_error = (string) self::get_setting( 'google_ads_last_error' );
      }

      usleep( 400000 );
    }

    return [
      'found' => count( $rows ) > 0,
      'scope' => $scope,
      'rows' => $rows,
      'error' => $last_error
    ];
  }

  // --- Kampanie: dane 30-dniowe ---

  public function get_products_for_date( $customer_id, $date )
  {
    $date = date( 'Y-m-d', strtotime( $date ) );

    $gaql_with_ad_group_with_url = "SELECT "
                                 . "segments.date, "
                                 . "segments.product_item_id, "
                                 . "segments.product_title, "
                                 . "segments.product_link, "
                                 . "campaign.id, "
                                 . "campaign.name, "
                                 . "campaign.status, "
                                 . "campaign.advertising_channel_type, "
                                 . "ad_group.id, "
                                 . "ad_group.name, "
                                 . "metrics.impressions, "
                                 . "metrics.clicks, "
                                 . "metrics.cost_micros, "
                                 . "metrics.conversions, "
                                 . "metrics.conversions_value "
                                 . "FROM shopping_performance_view "
                                 . "WHERE segments.date = '" . $date . "' "
                                 . "AND campaign.status = 'ENABLED' "
                                 . "AND campaign.advertising_channel_type = 'SHOPPING'";

    $gaql_with_ad_group = "SELECT "
                        . "segments.date, "
                        . "segments.product_item_id, "
                        . "segments.product_title, "
                        . "campaign.id, "
                        . "campaign.name, "
                        . "campaign.status, "
                        . "campaign.advertising_channel_type, "
                        . "ad_group.id, "
                        . "ad_group.name, "
                        . "metrics.impressions, "
                        . "metrics.clicks, "
                        . "metrics.cost_micros, "
                        . "metrics.conversions, "
                        . "metrics.conversions_value "
                        . "FROM shopping_performance_view "
                        . "WHERE segments.date = '" . $date . "' "
                        . "AND campaign.status = 'ENABLED' "
                        . "AND campaign.advertising_channel_type = 'SHOPPING'";

    $gaql_pmax_asset_group_with_url = "SELECT "
                                    . "segments.date, "
                                    . "segments.product_item_id, "
                                    . "segments.product_title, "
                                    . "segments.product_link, "
                                    . "campaign.id, "
                                    . "campaign.name, "
                                    . "campaign.status, "
                                    . "campaign.advertising_channel_type, "
                                    . "asset_group.id, "
                                    . "asset_group.name, "
                                    . "metrics.impressions, "
                                    . "metrics.clicks, "
                                    . "metrics.cost_micros, "
                                    . "metrics.conversions, "
                                    . "metrics.conversions_value "
                                    . "FROM asset_group_product_group_view "
                                    . "WHERE segments.date = '" . $date . "' "
                                    . "AND campaign.status = 'ENABLED' "
                                    . "AND campaign.advertising_channel_type = 'PERFORMANCE_MAX'";

    $gaql_pmax_asset_group = "SELECT "
                           . "segments.date, "
                           . "segments.product_item_id, "
                           . "segments.product_title, "
                           . "campaign.id, "
                           . "campaign.name, "
                           . "campaign.status, "
                           . "campaign.advertising_channel_type, "
                           . "asset_group.id, "
                           . "asset_group.name, "
                           . "metrics.impressions, "
                           . "metrics.clicks, "
                           . "metrics.cost_micros, "
                           . "metrics.conversions, "
                           . "metrics.conversions_value "
                           . "FROM asset_group_product_group_view "
                           . "WHERE segments.date = '" . $date . "' "
                           . "AND campaign.status = 'ENABLED' "
                           . "AND campaign.advertising_channel_type = 'PERFORMANCE_MAX'";

    $gaql_pmax_campaign_level_fallback_with_url = "SELECT "
                                                . "segments.date, "
                                                . "segments.product_item_id, "
                                                . "segments.product_title, "
                                                . "segments.product_link, "
                                                . "campaign.id, "
                                                . "campaign.name, "
                                                . "campaign.status, "
                                                . "campaign.advertising_channel_type, "
                                                . "metrics.impressions, "
                                                . "metrics.clicks, "
                                                . "metrics.cost_micros, "
                                                . "metrics.conversions, "
                                                . "metrics.conversions_value "
                                                . "FROM shopping_performance_view "
                                                . "WHERE segments.date = '" . $date . "' "
                                                . "AND campaign.status = 'ENABLED' "
                                                . "AND campaign.advertising_channel_type = 'PERFORMANCE_MAX'";

    $gaql_pmax_campaign_level_fallback = "SELECT "
                                       . "segments.date, "
                                       . "segments.product_item_id, "
                                       . "segments.product_title, "
                                       . "campaign.id, "
                                       . "campaign.name, "
                                       . "campaign.status, "
                                       . "campaign.advertising_channel_type, "
                                       . "metrics.impressions, "
                                       . "metrics.clicks, "
                                       . "metrics.cost_micros, "
                                       . "metrics.conversions, "
                                       . "metrics.conversions_value "
                                       . "FROM shopping_performance_view "
                                       . "WHERE segments.date = '" . $date . "' "
                                       . "AND campaign.status = 'ENABLED' "
                                       . "AND campaign.advertising_channel_type = 'PERFORMANCE_MAX'";

    $search_with_optional_url = function( $query_with_url, $query_without_url ) use ( $customer_id )
    {
      $result = $this -> search_stream( $customer_id, $query_with_url );
      if ( is_array( $result ) )
      {
        return $result;
      }

      return $this -> search_stream( $customer_id, $query_without_url );
    };

    $results_with_ad_group = $search_with_optional_url( $gaql_with_ad_group_with_url, $gaql_with_ad_group );
    $results_pmax_asset_group = $search_with_optional_url( $gaql_pmax_asset_group_with_url, $gaql_pmax_asset_group );
    $results_pmax_campaign_fallback = [];

    $had_success = false;

    if ( is_array( $results_with_ad_group ) )
    {
      $had_success = true;
    }
    else
    {
      $results_with_ad_group = [];
    }

    if ( is_array( $results_pmax_asset_group ) )
    {
      $had_success = true;
    }
    else
    {
      $results_pmax_asset_group = [];

      // Fallback dla kont/API, gdzie asset_group_product_group_view moze nie byc dostepny.
      $tmp = $search_with_optional_url( $gaql_pmax_campaign_level_fallback_with_url, $gaql_pmax_campaign_level_fallback );
      if ( is_array( $tmp ) )
      {
        $had_success = true;
        foreach ( $tmp as $row )
        {
          $channel = (string) ( $row['campaign']['advertisingChannelType'] ?? '' );
          if ( strtoupper( $channel ) === 'PERFORMANCE_MAX' )
          {
            $results_pmax_campaign_fallback[] = $row;
          }
        }
      }
    }

    if ( !$had_success )
    {
      return false;
    }

    $products = [];

    $collect_rows = function( $rows, $group_kind ) use ( &$products )
    {
      if ( !is_array( $rows ) )
      {
        return;
      }

      foreach ( $rows as $row )
      {
        $channel_type = strtoupper( trim( (string) ( $row['campaign']['advertisingChannelType'] ?? '' ) ) );
        if ( $channel_type === 'SEARCH' )
        {
          continue;
        }

        $offer_id = trim( (string) ( $row['segments']['productItemId'] ?? '' ) );
        if ( $offer_id === '' )
        {
          continue;
        }

        $campaign_id = (int) ( $row['campaign']['id'] ?? 0 );
        $campaign_name = trim( (string) ( $row['campaign']['name'] ?? '' ) );
        if ( $campaign_name === '' && $campaign_id > 0 )
        {
          $campaign_name = 'Kampania #' . $campaign_id;
        }

        $ad_group_id = 0;
        $ad_group_name = 'PMax (bez grup reklam)';

        if ( $group_kind === 'ad_group' )
        {
          $ad_group_id = (int) ( $row['adGroup']['id'] ?? 0 );
          $ad_group_name = trim( (string) ( $row['adGroup']['name'] ?? '' ) );

          if ( $ad_group_id > 0 && $ad_group_name === '' )
          {
            $ad_group_name = 'Ad group #' . $ad_group_id;
          }
          else if ( $ad_group_id <= 0 )
          {
            $ad_group_name = 'PMax (bez grup reklam)';
          }
        }
        else if ( $group_kind === 'asset_group' )
        {
          $ad_group_id = (int) ( $row['assetGroup']['id'] ?? 0 );
          $ad_group_name = trim( (string) ( $row['assetGroup']['name'] ?? '' ) );

          if ( $ad_group_id > 0 && $ad_group_name === '' )
          {
            $ad_group_name = 'Asset group #' . $ad_group_id;
          }
          else if ( $ad_group_id <= 0 )
          {
            $ad_group_name = 'PMax (bez grup plikow)';
          }
        }

        $scope_key = $offer_id . '|' . $campaign_id . '|' . $ad_group_id;

        if ( !isset( $products[ $scope_key ] ) )
        {
          $initial_product_url = trim( (string) ( $row['segments']['productLink'] ?? '' ) );
          if ( $initial_product_url !== '' && $this -> is_likely_image_url( $initial_product_url ) )
          {
            $initial_product_url = '';
          }

          $products[ $scope_key ] = [
            'OfferId' => $offer_id,
            'ProductTitle' => (string) ( $row['segments']['productTitle'] ?? $offer_id ),
            'ProductUrl' => $initial_product_url,
            'CampaignId' => $campaign_id,
            'CampaignName' => $campaign_name,
            'AdGroupId' => $ad_group_id,
            'AdGroupName' => $ad_group_name,
            'Impressions' => 0,
            'Clicks' => 0,
            'Cost' => 0.0,
            'Conversions' => 0.0,
            'ConversionValue' => 0.0
          ];
        }

        if ( ( $products[ $scope_key ]['ProductUrl'] ?? '' ) === '' )
        {
          $candidate_url = trim( (string) ( $row['segments']['productLink'] ?? '' ) );
          if ( $candidate_url !== '' && !$this -> is_likely_image_url( $candidate_url ) )
          {
            $products[ $scope_key ]['ProductUrl'] = $candidate_url;
          }
        }

        $products[ $scope_key ]['Impressions'] += (int) ( $row['metrics']['impressions'] ?? 0 );
        $products[ $scope_key ]['Clicks'] += (int) ( $row['metrics']['clicks'] ?? 0 );
        $products[ $scope_key ]['Cost'] += (float) ( $row['metrics']['costMicros'] ?? 0 ) / 1000000;
        $products[ $scope_key ]['Conversions'] += (float) ( $row['metrics']['conversions'] ?? 0 );
        $products[ $scope_key ]['ConversionValue'] += (float) ( $row['metrics']['conversionsValue'] ?? 0 );
      }
    };

    $collect_rows( $results_with_ad_group, 'ad_group' );
    $collect_rows( $results_pmax_asset_group, 'asset_group' );
    $collect_rows( $results_pmax_campaign_fallback, 'campaign' );

    $product_urls_by_offer_id = $this -> get_product_urls_by_offer_id( $customer_id, $date );
    if ( !empty( $product_urls_by_offer_id ) )
    {
      foreach ( $products as &$product )
      {
        $offer_id = trim( (string) ( $product['OfferId'] ?? '' ) );
        if ( $offer_id === '' )
        {
          continue;
        }

        if ( trim( (string) ( $product['ProductUrl'] ?? '' ) ) !== '' )
        {
          continue;
        }

        if ( isset( $product_urls_by_offer_id[ $offer_id ] ) )
        {
          $product['ProductUrl'] = $product_urls_by_offer_id[ $offer_id ];
        }
      }
      unset( $product );
    }

    return array_values( $products );
  }

  private function gaql_field_leaf_to_json_key( $leaf )
  {
    $leaf = trim( (string) $leaf );
    if ( $leaf === '' )
    {
      return '';
    }

    $parts = explode( '_', strtolower( $leaf ) );
    $key = array_shift( $parts );
    foreach ( $parts as $part )
    {
      $key .= ucfirst( $part );
    }

    return $key;
  }

  private function is_likely_image_url( $url )
  {
    $url = trim( (string) $url );
    if ( $url === '' )
    {
      return false;
    }

    $path = strtolower( (string) parse_url( $url, PHP_URL_PATH ) );
    return (bool) preg_match( '/\.(jpg|jpeg|png|gif|webp|bmp|svg|avif)$/i', $path );
  }

  private function is_valid_merchant_product_url( $url )
  {
    $url = trim( (string) $url );
    if ( $url === '' )
    {
      return false;
    }

    if ( !filter_var( $url, FILTER_VALIDATE_URL ) )
    {
      return false;
    }

    return !$this -> is_likely_image_url( $url );
  }

  private function get_product_urls_by_offer_id( $customer_id, $date )
  {
    $date = date( 'Y-m-d', strtotime( $date ) );
    $last_error_before = self::get_setting( 'google_ads_last_error' );

    $url_fields = [
      'shopping_product.link',
      'shopping_product.product_link',
      'shopping_product.product_url',
      'shopping_product.landing_page'
    ];

    foreach ( $url_fields as $field )
    {
      $gaql = "SELECT "
            . "shopping_product.item_id, "
            . $field . " "
            . "FROM shopping_product "
            . "WHERE segments.date = '" . $date . "'";

      $rows = $this -> search_stream( $customer_id, $gaql );
      if ( !is_array( $rows ) )
      {
        continue;
      }

      $field_parts = explode( '.', $field );
      $leaf = end( $field_parts );
      $json_key = $this -> gaql_field_leaf_to_json_key( $leaf );
      $map = [];

      foreach ( $rows as $row )
      {
        $item_id = trim( (string) ( $row['shoppingProduct']['itemId'] ?? '' ) );
        if ( $item_id === '' )
        {
          continue;
        }

        $url = trim( (string) ( $row['shoppingProduct'][ $json_key ] ?? '' ) );
        if ( $url === '' || !filter_var( $url, FILTER_VALIDATE_URL ) || $this -> is_likely_image_url( $url ) )
        {
          continue;
        }

        if ( !isset( $map[ $item_id ] ) )
        {
          $map[ $item_id ] = $url;
        }
      }

      if ( !empty( $map ) )
      {
        return $map;
      }
    }

    self::set_setting( 'google_ads_last_error', $last_error_before );

    return [];
  }

  public function get_campaigns_30_days( $customer_id, $as_of_date = null )
  {
    $as_of_date = $as_of_date ? date( 'Y-m-d', strtotime( $as_of_date ) ) : date( 'Y-m-d' );
    $date_from = date( 'Y-m-d', strtotime( '-29 days', strtotime( $as_of_date ) ) );

    $gaql = "SELECT "
          . "campaign.id, "
          . "campaign.name, "
          . "campaign.advertising_channel_type, "
          . "campaign.bidding_strategy_type, "
          . "campaign.target_roas.target_roas, "
          . "campaign_budget.amount_micros, "
          . "metrics.cost_micros, "
          . "metrics.conversions_value "
          . "FROM campaign "
          . "WHERE campaign.status = 'ENABLED' "
          . "AND segments.date >= '" . $date_from . "' "
          . "AND segments.date <= '" . $as_of_date . "'";

    $results = $this -> search_stream( $customer_id, $gaql );
    if ( $results === false ) return false;

    // Agregacja po campaign.id (API zwraca wiersz per dzień per kampania)
    $campaigns = [];
    foreach ( $results as $row )
    {
      $cid = $row['campaign']['id'] ?? null;
      if ( !$cid ) continue;

      if ( !isset( $campaigns[ $cid ] ) )
      {
        $campaigns[ $cid ] = [
          'campaign_id'      => $cid,
          'campaign_name'    => $row['campaign']['name'] ?? '',
          'advertising_channel_type' => (string) ( $row['campaign']['advertisingChannelType'] ?? '' ),
          'bidding_strategy' => $row['campaign']['biddingStrategyType'] ?? 'UNKNOWN',
          'target_roas'      => isset( $row['campaign']['targetRoas']['targetRoas'] )
                                  ? (float) $row['campaign']['targetRoas']['targetRoas']
                                  : 0,
          'budget'           => isset( $row['campaignBudget']['amountMicros'] )
                                  ? (float) $row['campaignBudget']['amountMicros'] / 1000000
                                  : 0,
          'cost_total'       => 0,
          'conversion_value' => 0,
        ];
      }

      $campaigns[ $cid ]['cost_total']       += (float) ( $row['metrics']['costMicros'] ?? 0 );
      $campaigns[ $cid ]['conversion_value']  += (float) ( $row['metrics']['conversionsValue'] ?? 0 );
    }

    // Przeliczenie micros i ROAS
    foreach ( $campaigns as &$c )
    {
      $c['money_spent'] = $c['cost_total'] / 1000000;
      $c['roas_30_days'] = ( $c['money_spent'] > 0 )
        ? round( ( $c['conversion_value'] / $c['money_spent'] ) * 100, 2 )
        : 0;
      unset( $c['cost_total'] );
    }

    return array_values( $campaigns );
  }

  // --- Kampanie: dane all-time ---

  public function get_campaigns_all_time( $customer_id, $as_of_date = null )
  {
    $as_of_date = $as_of_date ? date( 'Y-m-d', strtotime( $as_of_date ) ) : null;

    $gaql_base = "SELECT "
               . "campaign.id, "
               . "metrics.cost_micros, "
               . "metrics.conversions_value "
               . "FROM campaign "
               . "WHERE campaign.status = 'ENABLED'";

    $results = false;
    if ( $as_of_date )
    {
      $gaql_with_date = $gaql_base . " AND segments.date <= '" . $as_of_date . "'";
      $results = $this -> search_stream( $customer_id, $gaql_with_date );

      // Fallback do starego sposobu, gdy filtr daty nie jest akceptowany na danym koncie.
      if ( $results === false )
      {
        $results = $this -> search_stream( $customer_id, $gaql_base );
      }
    }
    else
    {
      $results = $this -> search_stream( $customer_id, $gaql_base );
    }

    if ( $results === false ) return false;

    $campaigns = [];
    foreach ( $results as $row )
    {
      $cid = $row['campaign']['id'] ?? null;
      if ( !$cid ) continue;

      if ( !isset( $campaigns[ $cid ] ) )
      {
        $campaigns[ $cid ] = [
          'campaign_id' => $cid,
          'cost_micros_total' => 0.0,
          'conversion_value_total' => 0.0
        ];
      }

      $campaigns[ $cid ]['cost_micros_total'] += (float) ( $row['metrics']['costMicros'] ?? 0 );
      $campaigns[ $cid ]['conversion_value_total'] += (float) ( $row['metrics']['conversionsValue'] ?? 0 );
    }

    foreach ( $campaigns as &$campaign )
    {
      $cost = $campaign['cost_micros_total'] / 1000000;
      $value = $campaign['conversion_value_total'];

      $campaign = [
        'campaign_id' => $campaign['campaign_id'],
        'cost_all_time' => $cost,
        'conversion_value_all_time' => $value,
        'roas_all_time' => ( $cost > 0 ) ? round( ( $value / $cost ) * 100, 2 ) : 0
      ];
    }

    return array_values( $campaigns );
  }

  public function get_ad_groups_30_days( $customer_id, $as_of_date = null )
  {
    $as_of_date = $as_of_date ? date( 'Y-m-d', strtotime( $as_of_date ) ) : date( 'Y-m-d' );
    $date_from = date( 'Y-m-d', strtotime( '-29 days', strtotime( $as_of_date ) ) );

    $gaql = "SELECT "
          . "campaign.id, "
          . "ad_group.id, "
          . "ad_group.name, "
          . "metrics.impressions, "
          . "metrics.clicks, "
          . "metrics.cost_micros, "
          . "metrics.conversions, "
          . "metrics.conversions_value "
          . "FROM ad_group "
          . "WHERE campaign.status = 'ENABLED' "
          . "AND ad_group.status = 'ENABLED' "
          . "AND segments.date >= '" . $date_from . "' "
          . "AND segments.date <= '" . $as_of_date . "'";

    $results = $this -> search_stream( $customer_id, $gaql );
    if ( $results === false ) return false;

    return $this -> aggregate_ad_groups( $results );
  }

  public function get_ad_groups_all_time( $customer_id, $as_of_date = null )
  {
    $gaql = "SELECT "
          . "campaign.id, "
          . "ad_group.id, "
          . "ad_group.name, "
          . "metrics.impressions, "
          . "metrics.clicks, "
          . "metrics.cost_micros, "
          . "metrics.conversions, "
          . "metrics.conversions_value "
          . "FROM ad_group "
          . "WHERE campaign.status = 'ENABLED' "
          . "AND ad_group.status = 'ENABLED'";

    $results = false;
    if ( $as_of_date )
    {
      $as_of_date = date( 'Y-m-d', strtotime( $as_of_date ) );
      $gaql_with_date = $gaql . " AND segments.date <= '" . $as_of_date . "'";
      $results = $this -> search_stream( $customer_id, $gaql_with_date );

      // Fallback gdy konto nie akceptuje filtra daty na all-time.
      if ( $results === false )
      {
        $results = $this -> search_stream( $customer_id, $gaql );
      }
    }
    else
    {
      $results = $this -> search_stream( $customer_id, $gaql );
    }

    if ( $results === false ) return false;

    return $this -> aggregate_ad_groups( $results );
  }

  public function get_shopping_ad_group_offer_ids( $customer_id )
  {
    $query_variants = [
      "SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, ad_group_criterion.listing_group.case_value FROM ad_group_criterion WHERE campaign.status != 'REMOVED' AND ad_group.status != 'REMOVED' AND campaign.advertising_channel_type = 'SHOPPING' AND ad_group_criterion.type = 'LISTING_GROUP'",
      "SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, ad_group_criterion.listing_group.case_value.product_item FROM ad_group_criterion WHERE campaign.status != 'REMOVED' AND ad_group.status != 'REMOVED' AND campaign.advertising_channel_type = 'SHOPPING' AND ad_group_criterion.type = 'LISTING_GROUP'",
      "SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, ad_group_criterion.listing_group.case_value.product_item.id FROM ad_group_criterion WHERE campaign.status != 'REMOVED' AND ad_group.status != 'REMOVED' AND campaign.advertising_channel_type = 'SHOPPING' AND ad_group_criterion.type = 'LISTING_GROUP'",
      "SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, ad_group_criterion.listing_group.case_value.product_item.value FROM ad_group_criterion WHERE campaign.status != 'REMOVED' AND ad_group.status != 'REMOVED' AND campaign.advertising_channel_type = 'SHOPPING' AND ad_group_criterion.type = 'LISTING_GROUP'",
      "SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, product_group_view.resource_name, ad_group_criterion.listing_group.case_value FROM product_group_view WHERE campaign.status != 'REMOVED' AND ad_group.status != 'REMOVED' AND campaign.advertising_channel_type = 'SHOPPING'",
      "SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, product_group_view.resource_name, ad_group_criterion.listing_group.case_value.product_item FROM product_group_view WHERE campaign.status != 'REMOVED' AND ad_group.status != 'REMOVED' AND campaign.advertising_channel_type = 'SHOPPING'"
    ];

    $results = false;
    $last_error = '';
    $variant_errors = [];

    foreach ( $query_variants as $index => $gaql )
    {
      $tmp = $this -> search_stream( $customer_id, $gaql );
      if ( is_array( $tmp ) )
      {
        $results = $tmp;
        break;
      }

      $last_error = (string) self::get_setting( 'google_ads_last_error' );
      $variant_errors[] = 'V' . ( $index + 1 ) . ': ' . $last_error;
    }

    if ( !is_array( $results ) )
    {
      $diag = implode( ' || ', array_filter( $variant_errors ) );
      $error_to_save = $diag !== '' ? $diag : $last_error;

      if ( $error_to_save !== '' )
      {
        self::set_setting( 'google_ads_last_error', $error_to_save );
      }
      return false;
    }

    if ( !is_array( $results ) )
    {
      return [];
    }

    $collect_scalar_values = function( $value ) use ( &$collect_scalar_values )
    {
      $collected = [];

      if ( is_array( $value ) )
      {
        foreach ( $value as $nested )
        {
          $collected = array_merge( $collected, $collect_scalar_values( $nested ) );
        }
        return $collected;
      }

      if ( is_scalar( $value ) )
      {
        $tmp = trim( (string) $value );
        if ( $tmp !== '' )
        {
          $collected[] = $tmp;
        }
      }

      return $collected;
    };

    $extract_offer_ids = function( $row ) use ( $collect_scalar_values )
    {
      $candidates = [];

      $case_value = $row['productGroupView']['caseValue']
        ?? $row['product_group_view']['case_value']
        ?? $row['adGroupCriterion']['listingGroup']['caseValue']
        ?? $row['ad_group_criterion']['listing_group']['case_value']
        ?? [];

      if ( is_array( $case_value ) )
      {
        if ( isset( $case_value['productItem'] ) )
        {
          $candidates = array_merge( $candidates, $collect_scalar_values( $case_value['productItem'] ) );
        }

        if ( isset( $case_value['product_item'] ) )
        {
          $candidates = array_merge( $candidates, $collect_scalar_values( $case_value['product_item'] ) );
        }

        if ( isset( $case_value['productItemId'] ) )
        {
          $candidates[] = trim( (string) $case_value['productItemId'] );
        }

        if ( isset( $case_value['product_item_id'] ) )
        {
          $candidates[] = trim( (string) $case_value['product_item_id'] );
        }
      }

      $direct_candidates = [
        $row['adGroupCriterion']['listingGroup']['caseValue']['productItem'] ?? null,
        $row['ad_group_criterion']['listing_group']['case_value']['product_item'] ?? null,
        $row['adGroupCriterion']['listingGroup']['caseValue']['productItemId'] ?? null,
        $row['ad_group_criterion']['listing_group']['case_value']['product_item_id'] ?? null,
        $row['productGroupView']['caseValue']['productItem'] ?? null,
        $row['product_group_view']['case_value']['product_item'] ?? null,
      ];

      foreach ( $direct_candidates as $dc )
      {
        if ( $dc === null )
        {
          continue;
        }

        $candidates = array_merge( $candidates, $collect_scalar_values( $dc ) );
      }

      $candidates = array_values( array_unique( array_filter( array_map( function( $item )
      {
        return trim( (string) $item );
      }, $candidates ) ) ) );

      return $candidates;
    };

    $scopes = [];

    foreach ( $results as $row )
    {
      $campaign_id = (int) ( $row['campaign']['id'] ?? $row['campaignId'] ?? 0 );
      $ad_group_id = (int) ( $row['adGroup']['id'] ?? $row['ad_group']['id'] ?? $row['adGroupId'] ?? 0 );

      if ( $campaign_id <= 0 || $ad_group_id <= 0 )
      {
        continue;
      }

      $offer_ids = $extract_offer_ids( $row );

      if ( empty( $offer_ids ) )
      {
        continue;
      }

      $scope_key = $campaign_id . '|' . $ad_group_id;

      if ( !isset( $scopes[ $scope_key ] ) )
      {
        $scopes[ $scope_key ] = [
          'campaign_id' => $campaign_id,
          'campaign_name' => trim( (string) ( $row['campaign']['name'] ?? '' ) ),
          'ad_group_id' => $ad_group_id,
          'ad_group_name' => trim( (string) ( $row['adGroup']['name'] ?? $row['ad_group']['name'] ?? '' ) ),
          'offer_ids' => []
        ];
      }

      foreach ( $offer_ids as $offer_id )
      {
        $scopes[ $scope_key ]['offer_ids'][ $offer_id ] = true;
      }
    }

    foreach ( $scopes as &$scope )
    {
      $scope['offer_ids'] = array_values( array_keys( (array) $scope['offer_ids'] ) );
    }
    unset( $scope );

    return array_values( $scopes );
  }

  public function get_shopping_ad_group_offer_ids_from_shopping_product( $customer_id )
  {
    $gaql = "SELECT "
          . "campaign.id, "
          . "campaign.name, "
          . "campaign.status, "
          . "campaign.advertising_channel_type, "
          . "ad_group.id, "
          . "ad_group.name, "
          . "ad_group.status, "
          . "shopping_product.item_id "
          . "FROM shopping_product "
          . "WHERE campaign.status != 'REMOVED' "
          . "AND ad_group.status != 'REMOVED' "
          . "AND campaign.advertising_channel_type = 'SHOPPING' "
          . "AND segments.date DURING LAST_30_DAYS";

    $results = $this -> search_stream( $customer_id, $gaql );
    if ( $results === false )
    {
      return false;
    }

    if ( !is_array( $results ) )
    {
      return [];
    }

    $scopes = [];

    foreach ( $results as $row )
    {
      $campaign_id = (int) ( $row['campaign']['id'] ?? 0 );
      $ad_group_id = (int) ( $row['adGroup']['id'] ?? 0 );
      $offer_id = trim( (string) ( $row['shoppingProduct']['itemId'] ?? '' ) );

      if ( $campaign_id <= 0 || $ad_group_id <= 0 || $offer_id === '' )
      {
        continue;
      }

      $scope_key = $campaign_id . '|' . $ad_group_id;

      if ( !isset( $scopes[ $scope_key ] ) )
      {
        $scopes[ $scope_key ] = [
          'campaign_id' => $campaign_id,
          'campaign_name' => trim( (string) ( $row['campaign']['name'] ?? '' ) ),
          'ad_group_id' => $ad_group_id,
          'ad_group_name' => trim( (string) ( $row['adGroup']['name'] ?? '' ) ),
          'offer_ids' => []
        ];
      }

      $scopes[ $scope_key ]['offer_ids'][ $offer_id ] = true;
    }

    foreach ( $scopes as &$scope )
    {
      $scope['offer_ids'] = array_values( array_keys( (array) $scope['offer_ids'] ) );
    }
    unset( $scope );

    return array_values( $scopes );
  }

  public function get_shopping_ad_group_offer_ids_from_performance( $customer_id )
  {
    $gaql = "SELECT "
          . "campaign.id, "
          . "campaign.name, "
          . "campaign.status, "
          . "campaign.advertising_channel_type, "
          . "ad_group.id, "
          . "ad_group.name, "
          . "ad_group.status, "
          . "segments.product_item_id, "
          . "metrics.impressions "
          . "FROM shopping_performance_view "
          . "WHERE campaign.status != 'REMOVED' "
          . "AND ad_group.status != 'REMOVED' "
          . "AND campaign.advertising_channel_type = 'SHOPPING'";

    $results = $this -> search_stream( $customer_id, $gaql );
    if ( $results === false )
    {
      return false;
    }

    if ( !is_array( $results ) )
    {
      return [];
    }

    $scopes = [];

    foreach ( $results as $row )
    {
      $campaign_id = (int) ( $row['campaign']['id'] ?? 0 );
      $ad_group_id = (int) ( $row['adGroup']['id'] ?? 0 );
      $offer_id = trim( (string) ( $row['segments']['productItemId'] ?? '' ) );

      if ( $campaign_id <= 0 || $ad_group_id <= 0 || $offer_id === '' )
      {
        continue;
      }

      $scope_key = $campaign_id . '|' . $ad_group_id;

      if ( !isset( $scopes[ $scope_key ] ) )
      {
        $scopes[ $scope_key ] = [
          'campaign_id' => $campaign_id,
          'campaign_name' => trim( (string) ( $row['campaign']['name'] ?? '' ) ),
          'ad_group_id' => $ad_group_id,
          'ad_group_name' => trim( (string) ( $row['adGroup']['name'] ?? '' ) ),
          'offer_ids' => []
        ];
      }

      $scopes[ $scope_key ]['offer_ids'][ $offer_id ] = true;
    }

    foreach ( $scopes as &$scope )
    {
      $scope['offer_ids'] = array_values( array_keys( (array) $scope['offer_ids'] ) );
    }
    unset( $scope );

    return array_values( $scopes );
  }

  public function get_search_terms_30_days( $customer_id )
  {
    $gaql = "SELECT "
          . "campaign.id, "
          . "ad_group.id, "
          . "ad_group.name, "
          . "search_term_view.search_term, "
          . "metrics.impressions, "
          . "metrics.clicks, "
          . "metrics.cost_micros, "
          . "metrics.conversions, "
          . "metrics.conversions_value "
          . "FROM search_term_view "
          . "WHERE campaign.status != 'REMOVED' "
          . "AND ad_group.status != 'REMOVED' "
          . "AND metrics.clicks > 0 "
          . "AND segments.date DURING LAST_30_DAYS";

    $results = $this -> search_stream( $customer_id, $gaql );
    if ( $results === false ) return false;

    $terms = $this -> aggregate_search_terms( $results );

    $pmax_terms = $this -> get_pmax_search_terms_30_days( $customer_id );
    if ( $pmax_terms !== false && is_array( $pmax_terms ) && !empty( $pmax_terms ) )
    {
      $terms = array_merge( $terms, $pmax_terms );
    }

    return $terms;
  }

  public function get_search_terms_all_time( $customer_id )
  {
    $gaql = "SELECT "
          . "campaign.id, "
          . "ad_group.id, "
          . "ad_group.name, "
          . "search_term_view.search_term, "
          . "metrics.impressions, "
          . "metrics.clicks, "
          . "metrics.cost_micros, "
          . "metrics.conversions, "
          . "metrics.conversions_value "
          . "FROM search_term_view "
          . "WHERE campaign.status != 'REMOVED' "
          . "AND ad_group.status != 'REMOVED' "
          . "AND metrics.clicks > 0";

    $results = $this -> search_stream( $customer_id, $gaql );
    if ( $results === false ) return false;

    $terms = $this -> aggregate_search_terms( $results );

    $pmax_terms = $this -> get_pmax_search_terms_all_time( $customer_id );
    if ( $pmax_terms !== false && is_array( $pmax_terms ) && !empty( $pmax_terms ) )
    {
      $terms = array_merge( $terms, $pmax_terms );
    }

    return $terms;
  }

  public function get_ad_keywords_30_days( $customer_id )
  {
    $gaql = "SELECT "
          . "campaign.id, "
          . "ad_group.id, "
          . "ad_group_criterion.keyword.text, "
          . "ad_group_criterion.keyword.match_type, "
          . "ad_group_criterion.status, "
          . "metrics.impressions, "
          . "metrics.clicks, "
          . "metrics.cost_micros, "
          . "metrics.conversions, "
          . "metrics.conversions_value "
          . "FROM keyword_view "
          . "WHERE campaign.status != 'REMOVED' "
          . "AND ad_group.status != 'REMOVED' "
          . "AND ad_group_criterion.negative = FALSE "
          . "AND ad_group_criterion.status != 'REMOVED' "
          . "AND campaign.advertising_channel_type = 'SEARCH' "
          . "AND metrics.clicks > 0 "
          . "AND segments.date DURING LAST_30_DAYS";

    $results = $this -> search_stream( $customer_id, $gaql );
    if ( $results === false ) return false;

    return $this -> aggregate_ad_keywords( $results );
  }

  public function get_ad_keywords_all_time( $customer_id )
  {
    $gaql = "SELECT "
          . "campaign.id, "
          . "ad_group.id, "
          . "ad_group_criterion.keyword.text, "
          . "ad_group_criterion.keyword.match_type, "
          . "ad_group_criterion.status, "
          . "metrics.impressions, "
          . "metrics.clicks, "
          . "metrics.cost_micros, "
          . "metrics.conversions, "
          . "metrics.conversions_value "
          . "FROM keyword_view "
          . "WHERE campaign.status != 'REMOVED' "
          . "AND ad_group.status != 'REMOVED' "
          . "AND ad_group_criterion.negative = FALSE "
          . "AND ad_group_criterion.status != 'REMOVED' "
          . "AND campaign.advertising_channel_type = 'SEARCH' "
          . "AND metrics.clicks > 0";

    $results = $this -> search_stream( $customer_id, $gaql );
    if ( $results === false ) return false;

    return $this -> aggregate_ad_keywords( $results );
  }

  private function get_pmax_search_terms_30_days( $customer_id )
  {
    $gaql = "SELECT "
          . "campaign.id, "
          . "campaign_search_term_view.search_term, "
          . "metrics.impressions, "
          . "metrics.clicks, "
          . "metrics.cost_micros, "
          . "metrics.conversions, "
          . "metrics.conversions_value "
          . "FROM campaign_search_term_view "
          . "WHERE campaign.status != 'REMOVED' "
          . "AND campaign.advertising_channel_type = 'PERFORMANCE_MAX' "
          . "AND metrics.clicks > 0 "
          . "AND segments.date DURING LAST_30_DAYS";

    $results = $this -> search_stream( $customer_id, $gaql );
    if ( $results === false ) return false;

    return $this -> aggregate_campaign_search_terms( $results );
  }

  private function get_pmax_search_terms_all_time( $customer_id )
  {
    $gaql = "SELECT "
          . "campaign.id, "
          . "campaign_search_term_view.search_term, "
          . "metrics.impressions, "
          . "metrics.clicks, "
          . "metrics.cost_micros, "
          . "metrics.conversions, "
          . "metrics.conversions_value "
          . "FROM campaign_search_term_view "
          . "WHERE campaign.status != 'REMOVED' "
          . "AND campaign.advertising_channel_type = 'PERFORMANCE_MAX' "
          . "AND metrics.clicks > 0";

    $results = $this -> search_stream( $customer_id, $gaql );
    if ( $results === false ) return false;

    return $this -> aggregate_campaign_search_terms( $results );
  }

  public function get_search_terms_for_date( $customer_id, $date )
  {
    $date = date( 'Y-m-d', strtotime( $date ) );

    $gaql = "SELECT "
          . "campaign.id, "
          . "ad_group.id, "
          . "ad_group.name, "
          . "search_term_view.search_term, "
          . "metrics.impressions, "
          . "metrics.clicks, "
          . "metrics.cost_micros, "
          . "metrics.conversions, "
          . "metrics.conversions_value "
          . "FROM search_term_view "
          . "WHERE campaign.status != 'REMOVED' "
          . "AND ad_group.status != 'REMOVED' "
          . "AND metrics.clicks > 0 "
          . "AND segments.date = '{$date}'";

    $results = $this -> search_stream( $customer_id, $gaql );
    if ( $results === false ) return false;

    $terms = $this -> aggregate_search_terms( $results );

    $pmax_terms = $this -> get_pmax_search_terms_for_date( $customer_id, $date );
    if ( $pmax_terms !== false && is_array( $pmax_terms ) && !empty( $pmax_terms ) )
    {
      $terms = array_merge( $terms, $pmax_terms );
    }

    return $terms;
  }

  private function get_pmax_search_terms_for_date( $customer_id, $date )
  {
    $date = date( 'Y-m-d', strtotime( $date ) );

    $gaql = "SELECT "
          . "campaign.id, "
          . "campaign_search_term_view.search_term, "
          . "metrics.impressions, "
          . "metrics.clicks, "
          . "metrics.cost_micros, "
          . "metrics.conversions, "
          . "metrics.conversions_value "
          . "FROM campaign_search_term_view "
          . "WHERE campaign.status != 'REMOVED' "
          . "AND campaign.advertising_channel_type = 'PERFORMANCE_MAX' "
          . "AND metrics.clicks > 0 "
          . "AND segments.date = '{$date}'";

    $results = $this -> search_stream( $customer_id, $gaql );
    if ( $results === false ) return false;

    return $this -> aggregate_campaign_search_terms( $results );
  }

  public function get_negative_keywords( $customer_id )
  {
    $campaign_gaql = "SELECT "
                   . "campaign.id, "
                   . "campaign_criterion.keyword.text, "
                   . "campaign_criterion.keyword.match_type "
                   . "FROM campaign_criterion "
                   . "WHERE campaign.status != 'REMOVED' "
                   . "AND campaign_criterion.type = 'KEYWORD' "
                   . "AND campaign_criterion.negative = TRUE";

    $ad_group_gaql = "SELECT "
                   . "campaign.id, "
                   . "ad_group.id, "
                   . "ad_group_criterion.keyword.text, "
                   . "ad_group_criterion.keyword.match_type "
                   . "FROM ad_group_criterion "
                   . "WHERE campaign.status != 'REMOVED' "
                   . "AND ad_group.status != 'REMOVED' "
                   . "AND ad_group_criterion.type = 'KEYWORD' "
                   . "AND ad_group_criterion.negative = TRUE";

    $campaign_results = $this -> search_stream( $customer_id, $campaign_gaql );
    if ( $campaign_results === false ) return false;

    $ad_group_results = $this -> search_stream( $customer_id, $ad_group_gaql );
    if ( $ad_group_results === false ) return false;

    $negatives = [];
    $seen = [];

    foreach ( $campaign_results as $row )
    {
      $campaign_id = $row['campaign']['id'] ?? null;
      $text = trim( (string) ( $row['campaignCriterion']['keyword']['text'] ?? '' ) );
      $match_type = (string) ( $row['campaignCriterion']['keyword']['matchType'] ?? '' );

      if ( !$campaign_id || $text === '' )
      {
        continue;
      }

      $key = 'campaign|' . $campaign_id . '||' . strtolower( $text ) . '|' . $match_type;
      if ( isset( $seen[ $key ] ) )
      {
        continue;
      }

      $seen[ $key ] = true;
      $negatives[] = [
        'scope' => 'campaign',
        'campaign_id' => (int) $campaign_id,
        'ad_group_id' => null,
        'keyword_text' => $text,
        'match_type' => $match_type
      ];
    }

    foreach ( $ad_group_results as $row )
    {
      $campaign_id = $row['campaign']['id'] ?? null;
      $ad_group_id = $row['adGroup']['id'] ?? null;
      $text = trim( (string) ( $row['adGroupCriterion']['keyword']['text'] ?? '' ) );
      $match_type = (string) ( $row['adGroupCriterion']['keyword']['matchType'] ?? '' );

      if ( !$campaign_id || !$ad_group_id || $text === '' )
      {
        continue;
      }

      $key = 'ad_group|' . $campaign_id . '|' . $ad_group_id . '|' . strtolower( $text ) . '|' . $match_type;
      if ( isset( $seen[ $key ] ) )
      {
        continue;
      }

      $seen[ $key ] = true;
      $negatives[] = [
        'scope' => 'ad_group',
        'campaign_id' => (int) $campaign_id,
        'ad_group_id' => (int) $ad_group_id,
        'keyword_text' => $text,
        'match_type' => $match_type
      ];
    }

    return $negatives;
  }

  private function aggregate_ad_groups( $results )
  {
    $ad_groups = [];

    foreach ( $results as $row )
    {
      $campaign_id = $row['campaign']['id'] ?? null;
      $ad_group_id = $row['adGroup']['id'] ?? null;

      if ( !$campaign_id || !$ad_group_id )
      {
        continue;
      }

      $key = $campaign_id . '|' . $ad_group_id;

      if ( !isset( $ad_groups[ $key ] ) )
      {
        $ad_groups[ $key ] = [
          'campaign_id' => (int) $campaign_id,
          'ad_group_id' => (int) $ad_group_id,
          'ad_group_name' => (string) ( $row['adGroup']['name'] ?? '' ),
          'impressions' => 0,
          'clicks' => 0,
          'cost' => 0.0,
          'conversions' => 0.0,
          'conversion_value' => 0.0,
          'roas' => 0.0
        ];
      }

      $ad_groups[ $key ]['impressions'] += (int) ( $row['metrics']['impressions'] ?? 0 );
      $ad_groups[ $key ]['clicks'] += (int) ( $row['metrics']['clicks'] ?? 0 );
      $ad_groups[ $key ]['cost'] += (float) ( $row['metrics']['costMicros'] ?? 0 ) / 1000000;
      $ad_groups[ $key ]['conversions'] += (float) ( $row['metrics']['conversions'] ?? 0 );
      $ad_groups[ $key ]['conversion_value'] += (float) ( $row['metrics']['conversionsValue'] ?? 0 );
    }

    foreach ( $ad_groups as &$ad_group )
    {
      $ad_group['roas'] = ( $ad_group['cost'] > 0 )
        ? round( ( $ad_group['conversion_value'] / $ad_group['cost'] ) * 100, 2 )
        : 0;
    }

    return array_values( $ad_groups );
  }

  private function aggregate_search_terms( $results )
  {
    $terms = [];

    foreach ( $results as $row )
    {
      $campaign_id = $row['campaign']['id'] ?? null;
      $ad_group_id = $row['adGroup']['id'] ?? null;
      $search_term = trim( (string) ( $row['searchTermView']['searchTerm'] ?? '' ) );

      if ( !$campaign_id || !$ad_group_id || $search_term === '' )
      {
        continue;
      }

      $key = $campaign_id . '|' . $ad_group_id . '|' . strtolower( $search_term );

      if ( !isset( $terms[ $key ] ) )
      {
        $terms[ $key ] = [
          'campaign_id' => (int) $campaign_id,
          'ad_group_id' => (int) $ad_group_id,
          'ad_group_name' => (string) ( $row['adGroup']['name'] ?? '' ),
          'search_term' => $search_term,
          'impressions' => 0,
          'clicks' => 0,
          'cost' => 0.0,
          'conversions' => 0.0,
          'conversion_value' => 0.0,
          'roas' => 0.0
        ];
      }

      $terms[ $key ]['impressions'] += (int) ( $row['metrics']['impressions'] ?? 0 );
      $terms[ $key ]['clicks'] += (int) ( $row['metrics']['clicks'] ?? 0 );
      $terms[ $key ]['cost'] += (float) ( $row['metrics']['costMicros'] ?? 0 ) / 1000000;
      $terms[ $key ]['conversions'] += (float) ( $row['metrics']['conversions'] ?? 0 );
      $terms[ $key ]['conversion_value'] += (float) ( $row['metrics']['conversionsValue'] ?? 0 );
    }

    foreach ( $terms as $key => &$term )
    {
      if ( (int) $term['clicks'] <= 0 )
      {
        unset( $terms[ $key ] );
        continue;
      }

      $term['roas'] = ( $term['cost'] > 0 )
        ? round( ( $term['conversion_value'] / $term['cost'] ) * 100, 2 )
        : 0;
    }

    return array_values( $terms );
  }

  private function aggregate_campaign_search_terms( $results )
  {
    $terms = [];

    foreach ( $results as $row )
    {
      $campaign_id = $row['campaign']['id'] ?? null;
      $search_term = trim( (string) ( $row['campaignSearchTermView']['searchTerm'] ?? '' ) );

      if ( !$campaign_id || $search_term === '' )
      {
        continue;
      }

      $key = $campaign_id . '|0|' . strtolower( $search_term );

      if ( !isset( $terms[ $key ] ) )
      {
        $terms[ $key ] = [
          'campaign_id' => (int) $campaign_id,
          'ad_group_id' => 0,
          'ad_group_name' => 'PMax (bez grup reklam)',
          'search_term' => $search_term,
          'impressions' => 0,
          'clicks' => 0,
          'cost' => 0.0,
          'conversions' => 0.0,
          'conversion_value' => 0.0,
          'roas' => 0.0
        ];
      }

      $terms[ $key ]['impressions'] += (int) ( $row['metrics']['impressions'] ?? 0 );
      $terms[ $key ]['clicks'] += (int) ( $row['metrics']['clicks'] ?? 0 );
      $terms[ $key ]['cost'] += (float) ( $row['metrics']['costMicros'] ?? 0 ) / 1000000;
      $terms[ $key ]['conversions'] += (float) ( $row['metrics']['conversions'] ?? 0 );
      $terms[ $key ]['conversion_value'] += (float) ( $row['metrics']['conversionsValue'] ?? 0 );
    }

    foreach ( $terms as $key => &$term )
    {
      if ( (int) $term['clicks'] <= 0 )
      {
        unset( $terms[ $key ] );
        continue;
      }

      $term['roas'] = ( $term['cost'] > 0 )
        ? round( ( $term['conversion_value'] / $term['cost'] ) * 100, 2 )
        : 0;
    }

    return array_values( $terms );
  }

  private function aggregate_ad_keywords( $results )
  {
    $keywords = [];

    foreach ( $results as $row )
    {
      $campaign_id = $row['campaign']['id'] ?? null;
      $ad_group_id = $row['adGroup']['id'] ?? null;
      $keyword_text = trim( (string) ( $row['adGroupCriterion']['keyword']['text'] ?? '' ) );
      $match_type = trim( (string) ( $row['adGroupCriterion']['keyword']['matchType'] ?? '' ) );
      $status = trim( (string) ( $row['adGroupCriterion']['status'] ?? 'ENABLED' ) );

      if ( !$campaign_id || !$ad_group_id || $keyword_text === '' )
      {
        continue;
      }

      $key = $campaign_id . '|' . $ad_group_id . '|' . strtolower( $keyword_text ) . '|' . strtolower( $match_type );

      if ( !isset( $keywords[ $key ] ) )
      {
        $keywords[ $key ] = [
          'campaign_id' => (int) $campaign_id,
          'ad_group_id' => (int) $ad_group_id,
          'keyword_text' => $keyword_text,
          'match_type' => $match_type,
          'status' => $status,
          'impressions' => 0,
          'clicks' => 0,
          'cost' => 0.0,
          'conversions' => 0.0,
          'conversion_value' => 0.0,
          'roas' => 0.0,
        ];
      }

      $keywords[ $key ]['impressions'] += (int) ( $row['metrics']['impressions'] ?? 0 );
      $keywords[ $key ]['clicks'] += (int) ( $row['metrics']['clicks'] ?? 0 );
      $keywords[ $key ]['cost'] += (float) ( ( $row['metrics']['costMicros'] ?? 0 ) / 1000000 );
      $keywords[ $key ]['conversions'] += (float) ( $row['metrics']['conversions'] ?? 0 );
      $keywords[ $key ]['conversion_value'] += (float) ( $row['metrics']['conversionsValue'] ?? 0 );
    }

    foreach ( $keywords as &$item )
    {
      $item['roas'] = $item['cost'] > 0
        ? round( ( $item['conversion_value'] / $item['cost'] ) * 100, 2 )
        : 0;
    }
    unset( $item );

    return array_values( $keywords );
  }
}
