<?php

/**
 * SquareHelper
 *
 * Handles every direct request to the Square API for SquareWooSync Pro.
 * --------------------------------------------------------------------------
 *  • **Token precedence**
 *      1. AES-256-GCM token stored by {@see Pixeldev\SquareWooSync\Security\SquareTokenManager}.
 *      2. Legacy AES-256-CBC token saved in the plugin settings (for older installs).
 *
 *  • **Reliability**
 *      – Exponential back-off with jitter (429 / 500 / 503).  
 *      – FIFO queue with 200 ms spacing to evade rate limits.
 *
 *  • **Feature surface**
 *      – Webhook subscription helpers.  
 *      – Inventory & product synchronisation.  
 *      – Variation / image upload utilities.
 *
 * @package Pixeldev\SquareWooSync\Square
 */

namespace Pixeldev\SquareWooSync\Square;

use Pixeldev\SquareWooSync\Security\SquareTokenManager;
use Pixeldev\SquareWooSync\Woo\WooImport;

defined('ABSPATH') || exit;

/**
 * Class SquareHelper
 */
class SquareHelper
{

  /** @var string|null Decrypted token chosen during instantiation */
  private $access_token;

  /** @var string Legacy static CBC key (kept until all sites migrate) */
  private $encryption_key = 'EE8E1E71AA6E692DB5B7C6E2AEB7D';

  /** @var array<int,array<string,mixed>> Simple FIFO queue */
  private static $request_queue = [];

  /** @var bool Processing flag */
  private static $processing = false;

  /** @var array<string> Mapping of Woo→Square categories created in-flight */
  private $square_categories = [];

  /* ─────────────────────────────────────────────────────────────────────
	 *  ── Lifecycle ──
	 * ─────────────────────────────────────────────────────────────────── */

  /**
   * Constructor.
   * Resolves the correct access token and caches it.
   */
  public function __construct()
  {
    $this->access_token = $this->resolve_access_token();
  }

  /**
   * Choose the best available access token.
   *
   * Order: new OAuth token → legacy encrypted token → null.
   *
   * @return string|null Plain token or null when none exists.
   */
  private function resolve_access_token(): ?string
  {
    // 1. Preferred AES-GCM token.
    $oauth_token = SquareTokenManager::get();
    if ($oauth_token) {
      return $oauth_token;
    }

    // 2. Fallback to legacy AES-CBC token (settings option).
    $settings         = get_option('square-woo-sync_settings', []);
    $legacy_encrypted = $settings['access_token'] ?? null;

    return $legacy_encrypted
      ? $this->decrypt_access_token($legacy_encrypted)
      : null;
  }

  /* ─────────────────────────────────────────────────────────────────────
	 *  ── Core Request Logic ──
	 * ─────────────────────────────────────────────────────────────────── */

  /**
   * Perform a Square API call with automatic retries.
   *
   * @param string      $endpoint     Endpoint beginning with `/`.
   * @param string      $method       HTTP verb (`GET`, `POST`, …).
   * @param array|null  $body         Payload (will be json-encoded).
   * @param string|null $op_token     Override bearer token for one request.
   * @param bool        $use_backoff  Retry on 429/500/503?
   *
   * @return array{success:bool, data?:mixed, error?:string}
   */
  public function square_api_request(
    string $endpoint,
    string $method      = 'GET',
    $body               = null,
    ?string $op_token   = null,
    bool $use_backoff   = true
  ): array {

    $settings  = get_option('square-woo-sync_settings', []);
    $token     = $op_token ?? $this->access_token;

    $base_url  = (isset($settings['environment']) && 'sandbox' === $settings['environment'])
      ? 'https://connect.squareupsandbox.com/v2'
      : 'https://connect.squareup.com/v2';

    $url  = $base_url . $endpoint;
    $args = [
      'method'      => $method,
      'headers'     => [
        'Authorization' => 'Bearer ' . esc_attr($token),
        'Content-Type'  => 'application/json',
        'Accept'        => 'application/json',
      ],
      'body'        => $body ? wp_json_encode($body) : '',
      'data_format' => 'body',
    ];

    // Exponential back-off variables.
    $backoff_factor       = 2;
    $retry_interval       = 1;          // seconds
    $status_codes_retry   = [429, 500, 503];
    $max_retry_wait_time  = 60;         // seconds
    $max_retries          = 5;
    $retry_count          = 0;

    do {
      $response = wp_remote_request(esc_url_raw($url), $args);
      $code     = wp_remote_retrieve_response_code($response);

      if (in_array($code, $status_codes_retry, true) && $use_backoff) {
        $wait   = min(pow($backoff_factor, $retry_count) * $retry_interval, $max_retry_wait_time);
        $jitter = rand(0, 1000) / 1000;
        sleep($wait + $jitter);
        $retry_count++;
      } else {
        break;
      }
    } while ($retry_count <= $max_retries);

    $body_raw = wp_remote_retrieve_body($response);

    if (is_wp_error($response)) {
      error_log('[Square] WP_Error: ' . $response->get_error_message());
      return ['success' => false, 'error' => $response->get_error_message()];
    }

    if ($code >= 200 && $code < 300) {
      return ['success' => true, 'data' => json_decode($body_raw, true)];
    }

    // Non-2xx response → extract detail if present.
    $error_message = sprintf(
      'Square API request failed. Status: %s. Response: %s',
      $code,
      $body_raw
    );
    $decoded = json_decode($body_raw, true);
    if (isset($decoded['errors'][0]['detail'])) {
      $error_message = $decoded['errors'][0]['detail'];
    }

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

  /* ─────────────────────────────────────────────────────────────────────
	 *  ── Request Queue ──
	 * ─────────────────────────────────────────────────────────────────── */

  /**
   * Push a request into the queue.
   *
   * @param string         $endpoint Endpoint.
   * @param string         $method   HTTP verb.
   * @param array|null     $body     Payload.
   * @param string|null    $op_token Override token.
   * @param callable|null  $callback Callback that receives the response.
   *
   * @return void
   */
  public static function queue_request(
    string $endpoint,
    string $method       = 'GET',
    $body                = null,
    ?string $op_token    = null,
    ?callable $callback  = null
  ): void {
    self::$request_queue[] = compact('endpoint', 'method', 'body', 'op_token', 'callback');
    self::process_queue();
  }

  /**
   * Drain the FIFO queue with 200 ms spacing.
   *
   * @return void
   */
  private static function process_queue(): void
  {
    if (self::$processing) {
      return;
    }
    self::$processing = true;

    while (! empty(self::$request_queue)) {
      $req           = array_shift(self::$request_queue);
      $square_helper = new self();

      $response = $square_helper->square_api_request(
        $req['endpoint'],
        $req['method'],
        $req['body'],
        $req['op_token']
      );

      if (is_callable($req['callback'])) {
        call_user_func($req['callback'], $response);
      }
      usleep(200_000); // 200 ms.
    }

    self::$processing = false;
  }

  /* ─────────────────────────────────────────────────────────────────────
	 *  ── Webhooks & Token Tests ──
	 * ─────────────────────────────────────────────────────────────────── */

  /**
   * Get all webhook subscriptions (enabled + disabled).
   *
   * @return array<string,mixed>
   * @throws \Exception On API failure.
   */
  public function get_webhook_subscriptions(): array
  {
    $token = $this->get_access_token();

    if (isset($token)) {
      $response = $this->square_api_request('/webhooks/subscriptions?include_disabled=true', 'GET', null, $token);

      if (isset($response['errors'])) {
        throw new \Exception('Failed to retrieve webhook subscriptions: ' . json_encode($response['errors']));
      }

      return $response['data']['subscriptions'] ?? [];
    }

    return [];
  }

  /**
   * Enable or disable a specific webhook subscription.
   *
   * @param string $subscription_id UUID.
   * @param bool   $enabled         Desired state.
   *
   * @return array<string,mixed>
   * @throws \Exception On API failure.
   */
  public function update_webhook_subscription_status(string $subscription_id, bool $enabled): array
  {

    $token = $this->get_access_token();

    if (isset($token)) {

      $body     = ['subscription' => ['enabled' => $enabled]];
      $response = $this->square_api_request("/webhooks/subscriptions/{$subscription_id}", 'PUT', $body, $token);

      if (isset($response['errors'])) {
        throw new \Exception('Failed to update webhook subscription: ' . json_encode($response['errors']));
      }

      return $response;
    }

    return  [];
  }

  /**
   * Quick validation – can the token list locations?
   *
   * @param string|null $op_token Token to test instead of default.
   * @return bool True when request succeeds.
   */
  public function is_token_valid(?string $op_token = null): bool
  {
    $response = $this->square_api_request('/locations', 'GET', null, $op_token);
    return $response['success'] && ! empty($response['data']);
  }

  /**
   * Resolve *whichever* Square token exists (OAuth → legacy) and
   * make sure it actually works by pinging `/locations`.
   *
   * @return string|\WP_Error  The valid token string **or** a WP_Error.
   */
  public function get_token_and_validate()
  {
    // 2. Ask for the token it chose.
    $token  = $this->get_active_token();

    if (empty($token)) {
      return new \WP_Error(401, 'Square access token not set.');
    }

    // 3. Quick sanity-check (lists locations); pass the token explicitly
    //    so any future internal changes don’t affect this call.
    if (!$this->is_token_valid($token)) {
      return new \WP_Error(401, 'Invalid or expired Square access token.');
    }

    return $token;
  }


  /* ─────────────────────────────────────────────────────────────────────
	 *  ── Legacy AES-CBC Encryption (for backward compatibility) ──
	 * ─────────────────────────────────────────────────────────────────── */

  /**
   * Encrypt a token using AES-256-CBC (legacy path).
   *
   * @param string $token Plain text token.
   * @return string|false `hex(cipher)::hex(iv)` or false on failure.
   */
  public function encrypt_access_token(string $token)
  {
    $iv        = openssl_random_pseudo_bytes(openssl_cipher_iv_length('aes-256-cbc'));
    $encrypted = openssl_encrypt($token, 'aes-256-cbc', $this->encryption_key, 0, $iv);

    if (false === $encrypted) {
      error_log('[Square] Encryption failed.');
      return false;
    }
    return bin2hex($encrypted) . '::' . bin2hex($iv);
  }

  /**
   * Decrypt a legacy AES-CBC token.
   *
   * @param string $encrypted_token `hex(cipher)::hex(iv)`.
   * @return string|false Plain token or false on failure.
   */
  public function decrypt_access_token(string $encrypted_token)
  {
    [$hex_cipher, $hex_iv] = explode('::', $encrypted_token, 2);

    $cipher = hex2bin($hex_cipher);
    $iv     = hex2bin($hex_iv);

    if (false === $cipher || false === $iv) {
      error_log('[Square] Decryption setup failed.');
      return false;
    }

    $plain = openssl_decrypt($cipher, 'aes-256-cbc', $this->encryption_key, 0, $iv);
    if (false === $plain) {
      error_log('[Square] Decryption failed.');
    }
    return $plain;
  }

  /**
   * Retrieve the legacy token from plugin settings.
   * **Deprecated** – use {@see SquareTokenManager} for modern storage.
   *
   * @return string|null Plain token or null when absent.
   */
  public function get_access_token(): ?string
  {
    $settings = get_option('square-woo-sync_settings', []);
    $token    = $settings['access_token'] ?? null;
    return $token ? $this->decrypt_access_token($token) : null;
  }

  /* ─────────────────────────────────────────────────────────────────────
	 *  ── Product & Inventory Helpers ──
	 * ─────────────────────────────────────────────────────────────────── */

  /**
   * Fetch a single Square catalog object’s details.
   *
   * @param string $catalog_object_id Square catalog ID.
   * @return array{success:bool, data?:mixed, error?:string}
   */
  public function get_square_item_details(string $catalog_object_id): array
  {
    $response = $this->square_api_request("/catalog/object/{$catalog_object_id}");

    if ($response['success']) {
      return $response['data'];
    }

    error_log('[Square] Failed to fetch item: ' . $response['error']);
    return ['success' => false, 'error' => $response['error']];
  }

  /**
   * Update (or create) a product in Square based on Woo data.
   *
   * @param array $woo_data      Normalised WooCommerce product array.
   * @param array $square_data   Existing Square catalog object to update.
   * @param array $data_to_import Flags specifying which fields to sync.
   *
   * @return array{inventoryUpdateStatus?:mixed, productUpdateStatus:mixed}
   */
  public function update_square_product(
    array $woo_data,
    array $square_data,
    array $data_to_import
  ): array {

    $idempotency_key = uniqid('sq_', true);
    $wooExporter     = new WooImport();

    /* ---- text / description ---- */
    if (($data_to_import['title'] ?? false)) {
      if (($square_data['type'] ?? '') === 'ITEM') {
        $square_data['item_data']['name'] = $woo_data['name'];
      } else {
        $square_data['item_variation_data']['name'] = $woo_data['name'];
      }
    }
    if (($data_to_import['description'] ?? false)) {
      if (($square_data['type'] ?? '') === 'ITEM') {
        $square_data['item_data']['description'] = $woo_data['description'];
      } else {
        $square_data['item_variation_data']['description'] = $woo_data['description'];
      }
    }

    /* ---- stock ---- */
    $inventory_update_status = null;
    if ($data_to_import['stock'] ?? false) {
      $inventory  = $this->get_inventory($woo_data);
      $settings   = get_option('square-woo-sync_settings', []);
      $location   = $settings['location'];
      $inv_data   = [];

      if (! empty($inventory['data']['counts'])) {
        $inv_data = $this->updated_inventory_data($inventory['data']['counts'], $woo_data);
      } elseif (isset($woo_data['variations']) && is_array($woo_data['variations'])) {
        foreach ($woo_data['variations'] as $variation) {
          $catalog_id = $variation['variation_id'] ?? $variation['square_id'] ?? null;
          if (! $catalog_id) {
            continue;
          }
          $inv_data[] = [
            'catalog_object_id' => $catalog_id,
            'location_id'       => $location,
            'quantity'          => $variation['stock'],
            'state'             => 'IN_STOCK',
          ];
        }
      }

      if ($inv_data) {
        $inventory_update_status = $this->update_inventory($inv_data);
      }
    }

    /* ---- images ---- */
    $wc_product = wc_get_product($woo_data['id'] ?? 0);
    if ($wc_product && ($data_to_import['images'] ?? false) && ($square_data['type'] ?? '') === 'ITEM') {
      $image_ids = $wooExporter->upload_product_images_to_square($wc_product, $this);
      if ($image_ids) {
        $square_data['item_data']['image_ids'] = $image_ids;
      }
    }

    /* ---- categories ---- */
    if ($wc_product && ($data_to_import['categories'] ?? false)) {
      $terms = get_the_terms($wc_product->get_id(), 'product_cat');
      if ($terms && ! is_wp_error($terms)) {
        $cats = [];
        foreach ($terms as $t) {
          $cat_id = $wooExporter->get_or_create_square_category($t, $this);
          if ($cat_id) {
            $cats[] = ['id' => $cat_id];
          }
        }
        if ($cats) {
          $square_data['item_data']['categories'] = $cats;
        }
      }
    }

    /* ---- variations + inventory ---- */
    $this->update_variations($square_data, $woo_data, $data_to_import);

    /* ---- final upsert ---- */
    $body = [
      'idempotency_key' => $idempotency_key,
      'object'          => $square_data,
    ];

    $product_update_status = $this->square_api_request('/catalog/object', 'POST', $body);

    return [
      'inventoryUpdateStatus' => $inventory_update_status,
      'productUpdateStatus'   => $product_update_status,
    ];
  }

  /**
   * Update variations within a variable product object.
   *
   * @param array &$square_data Square catalog object (modified in-place).
   * @param array $woo_data     WooCommerce product normalised array.
   * @param array $data_to_import Flags specifying which fields to sync.
   *
   * @return void
   */
  private function update_variations(
    array &$square_data,
    array  $woo_data,
    array  $data_to_import
  ): void {
    if (($square_data['type'] ?? '') === 'ITEM') {
      // ── grab the inner array *by reference* ──
      $variations = &$square_data['item_data']['variations'];
      $this->update_variable_product_variations(
        $variations,
        $woo_data,
        $data_to_import
      );
    } else {
      // single-variation catalog object
      $this->update_variable_product_variations(
        $square_data,
        $woo_data,
        $data_to_import
      );
    }
  }

  /**
   * Update each Square variation against the matching Woo variation.
   *
   * @param array &$square_variations Array of Square variation objects.
   * @param array $woo_data           WooCommerce product array.
   * @param array $data_to_import     Field flags.
   *
   * @return void
   */
  private function update_variable_product_variations(array &$square_variations, array $woo_data, array $data_to_import): void
  {

    $woo_variations = $woo_data['variations'] ?? [];
    if (! $woo_variations) {
      return;
    }

    // Guarantee we always iterate over an array of variations.
    if (! isset($square_variations[0]) || ! is_array($square_variations[0])) {
      $square_variations = [$square_variations];
    }

    foreach ($square_variations as &$variation) {
      if (isset($woo_data['variation_id']) && $variation['id'] === $woo_data['variation_id']) {
        $this->update_simple_product_variation($variation, $this->getVariationById($woo_data, $woo_data['variation_id']), $data_to_import);
        continue;
      }

      if ($this->getVariationByParentId($woo_data, $variation['id'])) {
        $this->update_simple_product_variation($variation, $this->getVariationByParentId($woo_data, $variation['id']), $data_to_import);
      }
    }
  }

  /**
   * Helper – find Woo variation by its Woo variation ID.
   *
   * @param array  $product      Woo product array.
   * @param string $variation_id Woo variation ID.
   * @return array|null Matched variation or null.
   */
  public function getVariationById(array $product, string $variation_id): ?array
  {
    foreach ($product['variations'] as $variation) {
      if ($variation['square_id'] === $variation_id) {
        return $variation;
      }
    }
    return null;
  }

  /**
   * Helper – find Woo variation by its parent Square ID.
   *
   * @param array  $product   Woo product array.
   * @param string $parent_id Square parent ID.
   * @return array|null Matched variation or null.
   */
  public function getVariationByParentId(array $product, string $parent_id): ?array
  {
    foreach ($product['variations'] as $variation) {
      if ($variation['square_id'] === $parent_id) {
        return $variation;
      }
    }
    return null;
  }

  /**
   * Update simple (single) variation fields.
   *
   * @param array &$square_variation Square variation object (by reference).
   * @param array $woo_variation     WooCommerce variation data.
   * @param array $data_to_import    Field flags.
   *
   * @return void
   */
  private function update_simple_product_variation(
    array &$square_variation,
    array $woo_variation,
    array $data_to_import
  ): void {
    $wooExporter = new WooImport();

    if ($data_to_import['price'] ?? false) {
      $square_variation['item_variation_data']['price_money']['amount'] =
        (int) round((float) $woo_variation['price'] * 100);
    }

    if ($data_to_import['sku'] ?? false) {
      $square_variation['item_variation_data']['sku'] = $woo_variation['sku'];
    }

    // Image sync – only when explicitly enabled.
    if ($data_to_import['images'] ?? false) {
      $variation_obj = (! empty($woo_variation['id'])) ? wc_get_product($woo_variation['id']) : null;

      if ($variation_obj) {
        $img_id = $variation_obj->get_image_id();
        if ($img_id) {
          $sq_img_id = $wooExporter->upload_image_to_square($img_id, $this, false);
          if ($sq_img_id) {
            $square_variation['item_variation_data']['image_ids'] = [$sq_img_id];
          }
        }
      }
    }
  }

  /* ─────────────────────────────────────────────────────────────────────
	 *  ── Inventory Calls ──
	 * ─────────────────────────────────────────────────────────────────── */

  /**
   * Batch-create physical-count changes for inventory.
   *
   * @param array<int,array<string,mixed>> $inventory Array of counts.
   * @return array{success:bool,data?:mixed,error?:string}
   */
  public function update_inventory(array $inventory): array
  {
    $idempotency_key = uniqid('sq_', true);
    $occurred_at     = date('Y-m-d\TH:i:s.') . sprintf('%03d', (microtime(true) - floor(microtime(true))) * 1000) . 'Z';

    $changes = array_map(
      function ($inv) use ($occurred_at) {
        return [
          'physical_count' => [
            'catalog_object_id' => $inv['catalog_object_id'],
            'location_id'       => $inv['location_id'],
            'occurred_at'       => $occurred_at,
            'state'             => 'IN_STOCK',
            'quantity'          => (string) $inv['quantity'],
          ],
          'type'           => 'PHYSICAL_COUNT',
        ];
      },
      array_filter(
        $inventory,
        static fn($inv) => $inv['state'] === 'IN_STOCK'
      )
    );

    return $this->square_api_request(
      '/inventory/changes/batch-create',
      'POST',
      [
        'idempotency_key' => $idempotency_key,
        'changes'         => $changes,
      ]
    );
  }

  /**
   * Fetch inventory counts for each Woo variation or product.
   *
   * @param array $woo_data Woo product array.
   * @return array{success:bool,data?:mixed,error?:string}
   */
  public function get_inventory(array $woo_data): array
  {
    $settings    = get_option('square-woo-sync_settings', []);
    $location_id = $settings['location'];

    $body = [
      'catalog_object_ids' => [],
      'location_ids'       => [$location_id],
    ];

    if (isset($woo_data['variations']) && is_array($woo_data['variations'])) {
      foreach ($woo_data['variations'] as $variation) {
        $body['catalog_object_ids'][] =
          $variation['variation_id'] ?? $variation['square_id'] ?? null;
      }
    } elseif (isset($woo_data['square_id'])) {
      $body['catalog_object_ids'][] = $woo_data['square_id'];
    }

    $body['catalog_object_ids'] = array_filter($body['catalog_object_ids']);

    return $this->square_api_request('/inventory/counts/batch-retrieve', 'POST', $body);
  }

  /**
   * Map Woo quantities onto an existing Square inventory array.
   *
   * @param array $inventory Existing `counts` array from Square.
   * @param array $woo_data  Woo product array with variations.
   *
   * @return array Updated counts array.
   */
  public function updated_inventory_data(array $inventory, array $woo_data): array
  {

    if (empty($woo_data['variations'])) {
      return $inventory;
    }

    $settings    = get_option('square-woo-sync_settings', []);
    $location_id = $settings['location'];

    foreach ($inventory as &$inv) {
      if ($inv['location_id'] !== $location_id) {
        continue;
      }

      foreach ($woo_data['variations'] as $variation) {
        $match_id = $variation['variation_id'] ?? $variation['square_id'] ?? '';
        if ($match_id === $inv['catalog_object_id']) {
          $inv['quantity'] = $variation['stock'];
          break;
        }
      }
    }
    unset($inv);

    return $inventory;
  }

  /**
   * Return the access-token that was resolved in the constructor
   * (OAuth first, legacy CBC token as fallback).
   *
   * @phpstan-return non-empty-string|null
   */
  public function get_active_token(): ?string
  {
    return $this->access_token;
  }
}
