<?php
/**
 * Costudio_Standalone_License
 *
 * Handles ContentOS service license verification.
 *
 * Validation strategy:
 *   1. Customer enters the commercial EDD license key.
 *   2. The ContentOS API validates it and returns a FOS2 technical access key.
 *   3. The plugin stores FOS2 locally and uses it for authenticated API calls.
 *
 * @package Contentosapp_Studio
 * @since   1.0.0
 */

defined( 'ABSPATH' ) || exit;
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Queries on plugin-owned tables; $table = $wpdb->prefix . constant, not user input.

class Costudio_Standalone_License {

	/** @var self|null Singleton instance. */
	private static ?self $instance = null;

	/** Plan constants */
	const PLAN_FREE    = 'free';
	const PLAN_STARTER = 'starter';
	const PLAN_PRO     = 'pro';
	const PLAN_STUDIO  = 'studio';

	/** FOS2 technical access key format */
	const KEY_PREFIX = 'FOS2';

	/** Remote endpoint (used only for legacy keys and background re-checks) */
	const STORE_URL = COSTUDIO_API_URL;

	/** User-Agent de browser para requisições remotas (alguns WAF bloqueiam UA não-browser). */
	const BROWSER_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';

	/** WP option keys */
	const OPT_KEY        = 'costudio_license_key';
	const OPT_PLAN       = 'costudio_license_plan';
	const OPT_STATUS     = 'costudio_license_status';
	const OPT_EXPIRES    = 'costudio_license_expires';
	const OPT_EMAIL      = 'costudio_license_email';
	const OPT_ACTIVATED  = 'costudio_activated_at';
	const OPT_LAST_CHECK = 'costudio_license_last_check';
	const OPT_FAILURES   = 'costudio_license_check_failures';
	const OPT_KEY_TYPE   = 'costudio_license_key_type'; // 'fos2' | 'legacy'
	const OPT_COMMERCIAL = 'costudio_license_commercial_key';
	const OPT_SITE_LIMIT = 'costudio_license_site_limit';
	const OPT_QUOTA      = 'costudio_license_quota';
	const OPT_QUOTA_PLAN = 'costudio_license_quota_plan';
	const OPT_IMAGE_LIMIT      = 'costudio_license_image_limit';
	const OPT_IMAGE_LIMIT_PLAN = 'costudio_license_image_limit_plan';
	const OPT_TRIAL_REMAINING  = 'costudio_license_trial_remaining';

	/** @return self */
	public static function get_instance(): self {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	// ── Plan & capabilities ───────────────────────────────────────────────────

	/**
	 * Get current plan slug.
	 */
	public function get_plan(): string {
		$key = get_option( self::OPT_KEY, '' );
		if ( empty( $key ) ) {
			return self::PLAN_FREE;
		}
		return self::normalize_plan( (string) get_option( self::OPT_PLAN, self::PLAN_FREE ) );
	}

	/**
	 * Returns true if any paid license is active.
	 */
	public function is_valid(): bool {
		return $this->get_plan() !== self::PLAN_FREE;
	}

	/** Alias for is_valid(). */
	public static function is_pro(): bool {
		return self::get_instance()->is_valid();
	}

	/**
	 * Check if a built-in plugin capability is available locally.
	 */
	public static function can( string $capability ): bool {
		switch ( $capability ) {
			case 'image_generation':
			case 'search_grounding':
			case 'manual_review':
			case 'openai_claude':
			case 'planning_tab':
			case 'export_zip':
			case 'unlimited_productions':
			case 'custom_agents':
			case 'basic_customization':
			case 'advanced_customization':
			case 'priority_processing':
			case 'multiple_sites':
				return true;

			default:
				return false;
		}
	}

	/**
	 * Get the local destination site allowance.
	 */
	public function get_site_limit(): int {
		return PHP_INT_MAX;
	}

	private static function normalize_plan( string $plan ): string {
		$plan = strtolower( trim( $plan ) );
		switch ( $plan ) {
			case self::PLAN_FREE:
			case self::PLAN_STARTER:
			case self::PLAN_PRO:
			case self::PLAN_STUDIO:
				return $plan;
			default:
				return self::PLAN_FREE;
		}
	}

	private static function service_site_limit_for_plan( string $plan ): int {
		$plan = self::normalize_plan( $plan );
		switch ( $plan ) {
			case self::PLAN_STUDIO: return 15;
			case self::PLAN_PRO:    return 3;
			default:                return 1;
		}
	}

	/**
	 * Get the monthly production limit via ContentOS API.
	 */
	public function get_api_production_limit(): int {
		$plan         = $this->get_plan();
		$remote_plan  = (string) get_option( self::OPT_QUOTA_PLAN, '' );
		$remote_quota = (int) get_option( self::OPT_QUOTA, 0 );

		if ( $remote_quota > 0 && $remote_plan === $plan ) {
			return $remote_quota;
		}

		switch ( $plan ) {
			case self::PLAN_STUDIO: return 100;
			case self::PLAN_PRO:    return 30;
			case self::PLAN_STARTER: return 5;
			case self::PLAN_FREE:
			default:                return 3;
		}
	}

	/**
	 * Get the managed ContentOS Auto image allowance per article.
	 */
	public function get_managed_image_limit_per_article(): int {
		$plan          = $this->get_plan();
		$remote_plan   = (string) get_option( self::OPT_IMAGE_LIMIT_PLAN, '' );
		$remote_option = get_option( self::OPT_IMAGE_LIMIT, null );

		if ( null !== $remote_option && false !== $remote_option && $remote_plan === $plan ) {
			return max( 0, min( 10, (int) $remote_option ) );
		}

		return self::image_limit_fallback_for_plan( $plan );
	}

	private static function image_limit_fallback_for_plan( string $plan ): int {
		$plan = self::normalize_plan( $plan );
		switch ( $plan ) {
			case self::PLAN_STUDIO: return 5;
			case self::PLAN_PRO:    return 3;
			case self::PLAN_STARTER:
			case self::PLAN_FREE:
			default:                return 1;
		}
	}

	/**
	 * Remaining lifetime Free trial allowance as last synced from the server.
	 */
	public function get_trial_remaining(): int {
		if ( self::PLAN_FREE !== $this->get_plan() ) {
			return 0;
		}

		$remaining = get_option( self::OPT_TRIAL_REMAINING, null );
		if ( null === $remaining || false === $remaining || '' === $remaining ) {
			return max( 0, $this->get_api_production_limit() );
		}

		return max( 0, min( $this->get_api_production_limit(), (int) $remaining ) );
	}

	/**
	 * Check if user can start a new production.
	 */
	public static function can_start_production(): bool {
		return true;
	}

	/**
	 * Get the current production count for the month — ContentOS Auto service quota only.
	 *
	 * BYOK productions are tagged with `inputs.ai_mode === 'byok'` at creation time
	 * and excluded here. Legacy rows missing the field count as Auto (intended).
	 */
	public static function get_monthly_count(): int {
		// 1. Transient (cache de 1 hora ou recém-sincronizado)
		$remote_count = get_transient( 'costudio_remote_usage_count' );
		if ( false !== $remote_count ) {
			return (int) $remote_count;
		}

		// 2. A API é soberana. Puxa a última memória gravada no banco para este mês.
		// Se a API resetar a cota ou houver estorno, o valor menor será respeitado.
		$api_count = get_option( 'costudio_api_usage_' . gmdate( 'Y_m' ), false );
		if ( false !== $api_count ) {
			return (int) $api_count;
		}

		// 3. Fallback absoluto apenas se o plugin nunca tiver se comunicado
		// com a API neste mês atual (ex: virada de mês ou servidor offline).
		global $wpdb;
		$table = $wpdb->prefix . 'costudio_productions';

		$table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table;
		if ( ! $table_exists ) {
			return 0;
		}

		return (int) $wpdb->get_var( $wpdb->prepare(
			"SELECT COUNT(*) FROM {$table}
			 WHERE status != 'error'
			   AND created_at >= %s
			   AND COALESCE(inputs, '') NOT LIKE %s
			   AND COALESCE(inputs, '') NOT LIKE %s",
			gmdate( 'Y-m-01 00:00:00' ),
			'%"execution_ai_mode":"byok"%',
			'%"ai_mode":"byok"%'
		) );
	}

	/**
	 * Count BYOK productions created this month.
	 *
	 * This is intentionally separate from get_monthly_count(), which represents
	 * ContentOS Auto service quota. BYOK remains unlimited, but the dashboard
	 * still needs the real production volume for the month.
	 */
	public static function get_monthly_byok_production_count(): int {
		global $wpdb;

		$table = $wpdb->prefix . 'costudio_productions';
		$table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table;
		if ( ! $table_exists ) {
			return 0;
		}

		return (int) $wpdb->get_var( $wpdb->prepare(
			"SELECT COUNT(*) FROM {$table}
			 WHERE status = 'completed'
			   AND created_at >= %s
			   AND (
			       COALESCE(inputs, '') LIKE %s
			       OR (
			           COALESCE(inputs, '') NOT LIKE %s
			           AND COALESCE(inputs, '') LIKE %s
			       )
			   )",
			gmdate( 'Y-m-01 00:00:00' ),
			'%"execution_ai_mode":"byok"%',
			'%"execution_ai_mode":%',
			'%"ai_mode":"byok"%'
		) );
	}

	/**
	 * Force a synchronization of usage quota with the ContentOS server.
	 *
	 * Also revalidates the managed-service plan status for quota displays.
	 *
	 * @param bool $force_remote If true, bypasses any throttle or local checks.
	 * @return array|null Updated data { usage, limit, plan } or null on failure.
	 */
	public static function sync_remote_usage( bool $force_remote = false ): ?array {
		$key = get_option( self::OPT_KEY, '' );
		$is_server = ( defined( 'CAP_API_SERVER' ) && CAP_API_SERVER );
		$site_url = self::service_site_url();

		// Path C: Local check (when running on the contentosapp-api server itself)
		if ( $is_server && class_exists( 'CAP_Auth' ) ) {
			// We can bypass network and call the logic directly.
			$request = new WP_REST_Request( 'GET', '/contentosapp-api/v1/quota' );
			$request->set_param( 'site_url', $site_url );
			
			if ( ! empty( $key ) ) {
				$request->set_header( 'Authorization', 'Bearer ' . $key );
				// Generate signature for internal loopback.
				$ts      = time();
				$secret  = self::get_signing_secret( $key );
				$hash    = hash_hmac( 'sha256', $ts . '.' . '', $secret );
				$sig     = $ts . '.' . $hash;
				$request->set_header( 'X-FOS-Signature', $sig );
			}
			
			$auth = CAP_Auth::authenticate( $request );
			if ( ! is_wp_error( $auth ) ) {
				$local_plan = self::normalize_plan( (string) ( $auth['plan'] ?? self::PLAN_FREE ) );
				$status     = CAP_Quota::get_status( $auth['license_key'], $local_plan, (int) ( $auth['quota'] ?? 0 ) );
				$local_image_limit = self::image_limit_fallback_for_plan( $local_plan );
				if ( class_exists( 'CAP_Plan_Capabilities' ) && is_callable( [ 'CAP_Plan_Capabilities', 'get' ] ) ) {
					$local_image_limit = (int) CAP_Plan_Capabilities::get( $local_plan, 'image_count', $local_image_limit );
				}
				$response = [
					'license'     => 'active',
					'usage_count' => (int) $status['count'],
					'limit'       => (int) $auth['quota'],
					'site_limit'  => (int) ( $auth['site_limit'] ?? 1 ),
					'plan'        => $local_plan,
					'expires'     => 'lifetime',
					'quota_type'  => $status['quota_type'] ?? 'monthly',
					'trial_remaining' => $status['trial_remaining'] ?? null,
					'capabilities' => [
						'image_count' => max( 0, min( 10, $local_image_limit ) ),
					],
				];
			} else {
				$response = $auth;
			}
		} else {
			// Try Path A: Standard EDD check (REST/Main URL).
			// Skip Path A entirely when we recently learned it is blocked on
			// this host (Cloudflare/Hostinger 403 on /?edd_action=check_license
			// is a known scenario). The skip transient self-expires every 6h
			// so we re-probe — if the host's WAF rules change, we recover.
			$skip_path_a = (bool) get_transient( 'costudio_path_a_blocked' );

			if ( empty( $key ) ) {
				$response = self::remote_ajax_request( 'cap_api_quota', '', [ 'site_url' => $site_url ] );
			} elseif ( self::is_fos2_key( $key ) ) {
				$response = self::remote_ajax_request( 'cap_api_quota', $key );
			} elseif ( ! $skip_path_a ) {
				$response = self::remote_request( 'check_license', $key );

				if ( is_wp_error( $response ) ) {
					// Memorize so the next call goes straight to Path B and
					// doesn't pollute Query Monitor / error logs with the same
					// known-failed request.
					set_transient( 'costudio_path_a_blocked', 1, 6 * HOUR_IN_SECONDS );
					$response = self::remote_ajax_request( 'cap_api_quota', $key );
				}
			} else {
				// Path B only — Path A was blocked recently.
				$response = self::remote_ajax_request( 'cap_api_quota', $key );
			}
		}

		if ( is_wp_error( $response ) ) {
			set_transient( 'costudio_last_sync_error', $response->get_error_message(), 60 );
			return null;
		}

		$status = $response['license'] ?? 'active'; // Quota endpoint might not return 'license' explicitly.
		
		// Map CAP quota response to EDD format if needed.
		$usage = null;
		if ( isset( $response['usage_count'] ) ) {
			$usage = (int) $response['usage_count'];
		} elseif ( isset( $response['used'] ) ) {
			$usage = (int) $response['used'];
		}

		$plan = self::plan_from_license_data( $response );
		$site_limit = self::site_limit_from_license_data( $response, $plan );
		$remote_limit = null;
		if ( isset( $response['limit'] ) ) {
			$limit_candidate = (int) $response['limit'];
			if ( $limit_candidate > 0 ) {
				$remote_limit = max( 1, min( 10000, $limit_candidate ) );
			}
		}
		$remote_image_limit = null;
		$remote_capabilities = isset( $response['capabilities'] ) && is_array( $response['capabilities'] )
			? $response['capabilities']
			: [];
		if ( array_key_exists( 'image_count', $remote_capabilities ) ) {
			$image_limit_candidate = (int) $remote_capabilities['image_count'];
			if ( $image_limit_candidate >= 0 ) {
				$remote_image_limit = max( 0, min( 10, $image_limit_candidate ) );
			}
		} elseif ( isset( $response['image_limit'] ) ) {
			$image_limit_candidate = (int) $response['image_limit'];
			if ( $image_limit_candidate >= 0 ) {
				$remote_image_limit = max( 0, min( 10, $image_limit_candidate ) );
			}
		} elseif ( isset( $response['image_count'] ) ) {
			$image_limit_candidate = (int) $response['image_count'];
			if ( $image_limit_candidate >= 0 ) {
				$remote_image_limit = max( 0, min( 10, $image_limit_candidate ) );
			}
		}

		if ( $status === 'valid' || $status === 'active' ) {
			update_option( self::OPT_PLAN,       $plan, false );
			update_option( self::OPT_STATUS,     self::PLAN_FREE === $plan ? self::PLAN_FREE : 'active', false );
			update_option( self::OPT_EXPIRES,    $response['expires'] ?? 'lifetime', false );
			update_option( self::OPT_LAST_CHECK, time(), false );
			update_option( self::OPT_FAILURES,   0, false );
			update_option( self::OPT_SITE_LIMIT, $site_limit, false );

			if ( null !== $remote_limit ) {
				update_option( self::OPT_QUOTA,      $remote_limit, false );
				update_option( self::OPT_QUOTA_PLAN, $plan, false );
			}
			if ( null !== $remote_image_limit ) {
				update_option( self::OPT_IMAGE_LIMIT,      $remote_image_limit, false );
				update_option( self::OPT_IMAGE_LIMIT_PLAN, $plan, false );
			}
			if ( isset( $response['trial_remaining'] ) ) {
				update_option( self::OPT_TRIAL_REMAINING, (int) $response['trial_remaining'], false );
			} elseif ( self::PLAN_FREE !== $plan ) {
				delete_option( self::OPT_TRIAL_REMAINING );
			}
		}

		if ( null !== $usage ) {
			set_transient( 'costudio_remote_usage_count', $usage, HOUR_IN_SECONDS );
			update_option( 'costudio_api_usage_' . gmdate( 'Y_m' ), $usage, false );
		}

		// Persist the addon (extra quotas) block. Always set the transient so
		// callers can distinguish "never synced" (transient false) from "synced,
		// addon disabled" (transient with enabled=false) — without this, every
		// License page render on an older server would trigger a re-sync.
		$addon_payload = isset( $response['addon'] ) && is_array( $response['addon'] )
			? $response['addon']
			: [
				'enabled'          => false,
				'balance'          => 0,
				'total_purchased'  => 0,
				'total_consumed'   => 0,
				'used_this_month'  => 0,
				'last_purchase_at' => null,
			];
		// Backfill `used_this_month` for older API servers that don't yet
		// emit the field — keeps the local cache shape stable so consumers
		// can rely on it without null-checks.
		if ( ! isset( $addon_payload['used_this_month'] ) ) {
			$addon_payload['used_this_month'] = 0;
		}
		set_transient( 'costudio_addon_balance', $addon_payload, HOUR_IN_SECONDS );
		update_option( 'costudio_addon_balance_cache', $addon_payload, false );

		delete_transient( 'costudio_last_sync_error' );

		$res = [
			'usage_count' => $usage,
			'plan'        => $plan,
			'limit'       => null !== $remote_limit ? $remote_limit : self::get_instance()->get_api_production_limit(),
			'image_limit' => null !== $remote_image_limit ? $remote_image_limit : self::get_instance()->get_managed_image_limit_per_article(),
			'site_limit'  => $site_limit,
			'status'      => $status,
			'capabilities' => [
				'image_count' => null !== $remote_image_limit ? $remote_image_limit : self::get_instance()->get_managed_image_limit_per_article(),
			],
			'trial_remaining' => self::PLAN_FREE === $plan ? self::get_instance()->get_trial_remaining() : null,
			'addon'       => $response['addon'] ?? null,
		];
		costudio_log_debug( 'license.sync_remote_usage.success', [
			'usage_count' => $usage,
			'plan'        => $plan,
			'limit'       => $res['limit'],
			'image_limit' => $res['image_limit'],
			'status'      => $status,
			'addon'       => [
				'enabled'         => (bool) ( $addon_payload['enabled'] ?? false ),
				'balance'         => (int) ( $addon_payload['balance'] ?? 0 ),
				'used_this_month' => (int) ( $addon_payload['used_this_month'] ?? 0 ),
			],
		] );
		return $res;
	}

	/**
	 * Read the cached addon (extra quotas) balance.
	 *
	 * Source of truth lives on contentosapp-api; this is the local cache
	 * populated by sync_remote_usage(). Returns a normalised shape even when
	 * nothing has been synced yet so callers never need to null-check.
	 *
	 * @return array { enabled: bool, balance: int, total_purchased: int, total_consumed: int, used_this_month: int, last_purchase_at: ?string }
	 */
	public static function get_addon_balance(): array {
		$cached = get_transient( 'costudio_addon_balance' );
		if ( ! is_array( $cached ) ) {
			$cached = get_option( 'costudio_addon_balance_cache', false );
		}

		if ( is_array( $cached ) ) {
			return [
				'enabled'          => (bool) ( $cached['enabled'] ?? false ),
				'balance'          => (int) ( $cached['balance'] ?? 0 ),
				'total_purchased'  => (int) ( $cached['total_purchased'] ?? 0 ),
				'total_consumed'   => (int) ( $cached['total_consumed'] ?? 0 ),
				'used_this_month'  => (int) ( $cached['used_this_month'] ?? 0 ),
				'last_purchase_at' => $cached['last_purchase_at'] ?? null,
			];
		}

		return [
			'enabled'          => false,
			'balance'          => 0,
			'total_purchased'  => 0,
			'total_consumed'   => 0,
			'used_this_month'  => 0,
			'last_purchase_at' => null,
		];
	}

	/**
	 * Fetch the addon purchase history from the remote server.
	 *
	 * Cached for 5 minutes so the License page rendering is fast even when
	 * the user expands the history section repeatedly. Bypass with
	 * $force_remote = true (e.g. after a successful purchase).
	 *
	 * Mirrors the dual-path strategy of sync_remote_usage:
	 *   - Path C: local call when running on the server itself.
	 *   - Path B: admin-ajax fallback (the path that actually works on
	 *             Cloudflare-fronted hosts; REST is intentionally skipped
	 *             because Sprint B.1 found wp-json blocked in production).
	 *
	 * @param  bool $force_remote Skip cache.
	 * @return array Purchase entries (newest first), empty array on failure.
	 */
	public static function fetch_addon_history( bool $force_remote = false ): array {
		$cached = get_transient( 'costudio_addon_history' );
		if ( ! $force_remote && is_array( $cached ) ) {
			return $cached;
		}

		$key       = (string) get_option( self::OPT_KEY, '' );
		$is_server = ( defined( 'CAP_API_SERVER' ) && CAP_API_SERVER );

		if ( empty( $key ) && ! $is_server ) {
			return [];
		}

		$purchases = [];

		// Path C — local loopback when running on contentosapp-api itself.
		if ( $is_server && class_exists( 'CAP_Auth' ) && class_exists( 'CAP_Routes' ) ) {
			$request = new WP_REST_Request( 'GET', '/contentosapp-api/v1/addons/history' );
			$request->set_param( 'site_url', self::service_site_url() );
			if ( ! empty( $key ) ) {
				$request->set_header( 'Authorization', 'Bearer ' . $key );
				$ts      = time();
				$secret  = self::get_signing_secret( $key );
				$hash    = hash_hmac( 'sha256', $ts . '.' . '', $secret );
				$request->set_header( 'X-FOS-Signature', $ts . '.' . $hash );
			}

			try {
				$response = CAP_Routes::handle_addons_history( $request );
				$data     = $response->get_data();
				if ( is_array( $data ) && isset( $data['purchases'] ) && is_array( $data['purchases'] ) ) {
					$purchases = $data['purchases'];
				}
			} catch ( \Throwable $e ) {
				costudio_log( 'license.fetch_addon_history.local_error', [ 'error' => $e->getMessage() ], 'warning' );
			}
		} else {
			// Path B — admin-ajax fallback (Cloudflare-friendly).
			$response = self::remote_ajax_request( 'cap_api_addons_history', $key );
			if ( ! is_wp_error( $response ) && is_array( $response ) && isset( $response['purchases'] ) && is_array( $response['purchases'] ) ) {
				$purchases = $response['purchases'];
			}
		}

		// Cache even an empty result briefly so a transient outage doesn't
		// hammer the server with retries.
		set_transient( 'costudio_addon_history', $purchases, 5 * MINUTE_IN_SECONDS );

		return $purchases;
	}

	/**
	 * Fetch the catalog of addon packages available for purchase.
	 *
	 * Cached for 1 hour because the package list changes very rarely (price
	 * tweaks, badge changes). The buy modal asks for this once per open;
	 * cache prevents 4x RTT during a comparison shopping session.
	 *
	 * Mirrors the dual-path strategy of fetch_addon_history:
	 *   - Path C: local CAP_Routes call when running on the server itself.
	 *   - Path B: admin-ajax fallback (REST is intentionally skipped because
	 *             Cloudflare/Hostinger frequently 403s wp-json).
	 *
	 * Returns the server payload as-is: { currency, packages: [...] }, or
	 * an empty array on any failure (caller renders an error state).
	 *
	 * @param  bool $force_remote Skip the transient cache.
	 * @return array { currency: string, packages: array } | []
	 */
	public static function fetch_addon_packages( bool $force_remote = false ): array {
		$cached = get_transient( 'costudio_addon_packages' );
		if ( ! $force_remote && is_array( $cached ) ) {
			return $cached;
		}

		$key       = (string) get_option( self::OPT_KEY, '' );
		$is_server = ( defined( 'CAP_API_SERVER' ) && CAP_API_SERVER );

		if ( empty( $key ) && ! $is_server ) {
			return [];
		}

		$payload = null;

		if ( $is_server && class_exists( 'CAP_Auth' ) && class_exists( 'CAP_Routes' ) ) {
			// Path C — local loopback (no network).
			$request = new WP_REST_Request( 'GET', '/contentosapp-api/v1/addons/packages' );
			$request->set_param( 'site_url', self::service_site_url() );
			if ( ! empty( $key ) ) {
				$request->set_header( 'Authorization', 'Bearer ' . $key );
				$ts      = time();
				$secret  = self::get_signing_secret( $key );
				$hash    = hash_hmac( 'sha256', $ts . '.' . '', $secret );
				$request->set_header( 'X-FOS-Signature', $ts . '.' . $hash );
			}

			try {
				$response = CAP_Routes::handle_addons_packages( $request );
				$data     = $response->get_data();
				if ( $response->get_status() === 200 && is_array( $data ) && isset( $data['packages'] ) && is_array( $data['packages'] ) ) {
					$payload = $data;
				}
			} catch ( \Throwable $e ) {
				costudio_log( 'license.fetch_addon_packages.local_error', [ 'error' => $e->getMessage() ], 'warning' );
			}
		} else {
			// Path B — admin-ajax fallback (Cloudflare-friendly).
			$response = self::remote_ajax_request( 'cap_api_addons_packages', $key );
			if ( ! is_wp_error( $response ) && is_array( $response ) && isset( $response['packages'] ) && is_array( $response['packages'] ) ) {
				$payload = $response;
			}
		}

		if ( $payload === null ) {
			return [];
		}

		set_transient( 'costudio_addon_packages', $payload, HOUR_IN_SECONDS );
		return $payload;
	}

	/**
	 * Create a Stripe Checkout Session on the server and return the redirect URL.
	 *
	 * Intentionally NOT cached — every call must mint a fresh session. The
	 * server enforces a 10-per-5-min rate limit per license, surfaced as
	 * WP_Error('rate_limited') so the caller can render a friendly retry message.
	 *
	 * Server contract reads JSON body (not form-urlencoded), so this method
	 * uses remote_ajax_json_request() rather than the legacy form-encoded helper.
	 * The dual-path strategy mirrors fetch_addon_packages (Path C local / Path B ajax).
	 *
	 * @param  string $package    Package id, must match CAP_Addon::PACKAGES (e.g. 'pack_30').
	 * @param  string $url_ok     HTTPS URL Stripe redirects to on success.
	 * @param  string $url_cancel HTTPS URL Stripe redirects to on cancel.
	 * @return array|WP_Error     { session_id, checkout_url, expires_at } or WP_Error.
	 */
	public static function create_addon_checkout( string $package, string $url_ok, string $url_cancel ) {
		$key       = (string) get_option( self::OPT_KEY, '' );
		$is_server = ( defined( 'CAP_API_SERVER' ) && CAP_API_SERVER );

		if ( empty( $key ) && ! $is_server ) {
			return new WP_Error( 'no_license', __( 'No active ContentOS Auto service license. Activate a service license before purchasing extra credits.', 'contentosapp-studio' ) );
		}

		$body = [
			'package'            => $package,
			'return_url_success' => $url_ok,
			'return_url_cancel'  => $url_cancel,
			'site_url'           => self::service_site_url(),
		];

		if ( $is_server && class_exists( 'CAP_Auth' ) && class_exists( 'CAP_Routes' ) ) {
			// Path C — local loopback.
			$body_str = wp_json_encode( $body );

			$request = new WP_REST_Request( 'POST', '/contentosapp-api/v1/addons/checkout' );
			$request->set_body( $body_str );
			$request->set_header( 'Content-Type', 'application/json' );

			if ( ! empty( $key ) ) {
				$request->set_header( 'Authorization', 'Bearer ' . $key );
				$ts      = time();
				$secret  = self::get_signing_secret( $key );
				$hash    = hash_hmac( 'sha256', $ts . '.' . $body_str, $secret );
				$request->set_header( 'X-FOS-Signature', $ts . '.' . $hash );
			} else {
				$request->set_param( 'site_url', self::service_site_url() );
			}

			try {
				$response = CAP_Routes::handle_addons_checkout( $request );
				$status   = $response->get_status();
				$data     = $response->get_data();

				if ( $status === 200 && is_array( $data ) && ! empty( $data['checkout_url'] ) ) {
					return $data;
				}

				$error_code = isset( $data['error']['code'] )    ? (string) $data['error']['code']    : 'checkout_failed';
				$error_msg  = isset( $data['error']['message'] ) ? (string) $data['error']['message'] : __( 'Could not create checkout session.', 'contentosapp-studio' );
				return new WP_Error( $error_code, $error_msg, [ 'status' => $status ] );
			} catch ( \Throwable $e ) {
				return new WP_Error( 'checkout_exception', $e->getMessage() );
			}
		}

		// Path B — admin-ajax with JSON body (server reads JSON, not form).
		return self::remote_ajax_json_request( 'cap_api_addons_checkout', $key, $body );
	}

	/**
	 * Obtain a Stripe Customer Portal URL for the current license.
	 *
	 * The portal is hosted by Stripe and lets the subscriber cancel, update
	 * payment method, or view invoices. Uses the same dual-path strategy as
	 * create_addon_checkout (Path C local loopback / Path B admin-ajax JSON).
	 *
	 * @param  string $return_url HTTPS URL Stripe redirects to after portal exit.
	 * @return array|WP_Error    { portal_url: string } or WP_Error.
	 */
	public static function get_subscription_portal_url( string $return_url ) {
		$key       = (string) get_option( self::OPT_KEY, '' );
		$is_server = ( defined( 'CAP_API_SERVER' ) && CAP_API_SERVER );

		if ( empty( $key ) && ! $is_server ) {
			return new WP_Error( 'no_license', __( 'No active ContentOS Auto service license found.', 'contentosapp-studio' ) );
		}

		$body = [
			'return_url' => $return_url,
			'site_url'   => self::service_site_url(),
		];

		if ( $is_server && class_exists( 'CAP_Auth' ) && class_exists( 'CAP_Routes' ) ) {
			// Path C — local loopback.
			$body_str = wp_json_encode( $body );

			$request = new WP_REST_Request( 'POST', '/contentosapp-api/v1/license/portal' );
			$request->set_body( $body_str );
			$request->set_header( 'Content-Type', 'application/json' );

			if ( ! empty( $key ) ) {
				$request->set_header( 'Authorization', 'Bearer ' . $key );
				$ts     = time();
				$secret = self::get_signing_secret( $key );
				$hash   = hash_hmac( 'sha256', $ts . '.' . $body_str, $secret );
				$request->set_header( 'X-FOS-Signature', $ts . '.' . $hash );
			} else {
				$request->set_param( 'site_url', self::service_site_url() );
			}

			try {
				$response = CAP_Routes::handle_license_portal( $request );
				$status   = $response->get_status();
				$data     = $response->get_data();

				if ( $status === 200 && is_array( $data ) && ! empty( $data['portal_url'] ) ) {
					return $data;
				}

				$error_code = isset( $data['error']['code'] )    ? (string) $data['error']['code']    : 'portal_failed';
				$error_msg  = isset( $data['error']['message'] ) ? (string) $data['error']['message'] : __( 'Could not create portal session.', 'contentosapp-studio' );
				return new WP_Error( $error_code, $error_msg, [ 'status' => $status ] );
			} catch ( \Throwable $e ) {
				return new WP_Error( 'portal_exception', $e->getMessage() );
			}
		}

		// Path B — admin-ajax with JSON body.
		return self::remote_ajax_json_request( 'cap_api_license_portal', $key, $body );
	}

	/**
	 * Get the current BYOK production count for the month (tracked locally).
	 */
	public static function get_monthly_byok_count(): int {
		return self::get_monthly_byok_production_count();
	}

	/**
	 * Increment BYOK production count.
	 */
	public static function increment_monthly_byok_count(): void {
	}

	// ── Activation & deactivation ─────────────────────────────────────────────

	/**
	 * Activate a license key.
	 *
	 * Commercial EDD keys are exchanged remotely for a FOS2 access key.
	 *
	 * @param  string $license_key
	 * @param  string $email        Purchase email used to validate the EDD license.
	 * @return array{ success: bool, plan: string, message: string }
	 */
	public static function activate_license( string $license_key, string $email = '' ): array {
		$license_key = trim( $license_key );
		$email       = sanitize_email( $email );

		if ( empty( $license_key ) ) {
			return self::deactivate_license();
		}

		if ( empty( $email ) || ! is_email( $email ) ) {
			return [
				'success' => false,
				'plan'    => self::PLAN_FREE,
				'message' => __( 'Enter the purchase email used for this license.', 'contentosapp-studio' ),
			];
		}

		// Exchange the commercial EDD key for a server-signed FOS2 access key.
		return self::activate_via_remote( $license_key, $email );
	}

	/**
	 * Get the current license key.
	 */
	public static function get_license_key(): string {
		return (string) get_option( self::OPT_KEY, '' );
	}

	/**
	 * Deactivate the license on this site.
	 *
	 * @return array{ success: bool, message: string }
	 */
	public static function deactivate_license(): array {
		$license_key = self::get_license_key();

		if ( ! empty( $license_key ) ) {
			// Best-effort remote deactivation. Failure is ignored locally.
			self::remote_ajax_request( 'cap_api_deactivate', $license_key, [ 'site_url' => self::service_site_url() ] );
		}

		delete_option( self::OPT_KEY );
		delete_option( self::OPT_COMMERCIAL );
		delete_option( self::OPT_EMAIL );
		delete_option( self::OPT_SITE_LIMIT );
		delete_option( self::OPT_QUOTA );
		delete_option( self::OPT_QUOTA_PLAN );
		delete_option( self::OPT_IMAGE_LIMIT );
		delete_option( self::OPT_IMAGE_LIMIT_PLAN );
		delete_option( self::OPT_TRIAL_REMAINING );

		return [
			'success' => true,
			'message' => __( 'Managed service license deactivated.', 'contentosapp-studio' ),
		];
	}

	// ── Periodic re-validation (WP-Cron) ─────────────────────────────────────

	/**
	 * Re-check the license status. Called daily by WP-Cron.
	 *
	 * FOS2 keys are checked against the ContentOS API/EDD on sync.
	 */
	public static function revalidate(): void {
		// Consolidate into sync_remote_usage which now does both.
		self::sync_remote_usage( true );
	}

	/**
	 * Get the secret used for request signing.
	 *
	 * @param string $license_key
	 * @return string
	 */
	public static function get_signing_secret( string $license_key ): string {
		return $license_key;
	}

	// ── Internal: remote activation ───────────────────────────────────────────

	/**
	 * Activate via ContentOS API and store the returned FOS2 access key.
	 */
	private static function activate_via_remote( string $license_key, string $email ): array {
		$response = self::remote_ajax_request(
			'cap_api_activate',
			$license_key,
			[
				'site_url'       => self::service_site_url(),
				'purchase_email' => $email,
			]
		);

		if ( is_wp_error( $response ) ) {
			return [
				'success' => false,
				'plan'    => self::normalize_plan( (string) get_option( self::OPT_PLAN, self::PLAN_FREE ) ),
				'message' => $response->get_error_message(),
			];
		}

		$data   = ( isset( $response['success'] ) && isset( $response['data'] ) && is_array( $response['data'] ) )
			? $response['data']
			: $response;
		$status = $data['license'] ?? ( ! empty( $response['success'] ) ? 'active' : 'invalid' );

		if ( $status === 'valid' || $status === 'active' ) {
			$access_key = (string) ( $data['access_key'] ?? '' );
			if ( ! self::is_fos2_key( $access_key ) ) {
				return [
					'success' => false,
					'plan'    => self::PLAN_FREE,
					'message' => __( 'License server did not return a valid access key. Please contact support.', 'contentosapp-studio' ),
				];
			}

			$plan = self::plan_from_license_data( $data );
			$site_limit = self::site_limit_from_license_data( $data, $plan );

			update_option( self::OPT_KEY,        $access_key, false );
			update_option( self::OPT_COMMERCIAL, $license_key, false );
			update_option( self::OPT_KEY_TYPE,   'fos2', false );
			update_option( self::OPT_PLAN,       $plan, false );
			update_option( self::OPT_STATUS,     'active', false );
			update_option( self::OPT_EXPIRES,    $data['expires'] ?? 'lifetime', false );
			update_option( self::OPT_ACTIVATED,  time(), false );
			update_option( self::OPT_LAST_CHECK, time(), false );
			update_option( self::OPT_FAILURES,   0, false );
			update_option( self::OPT_SITE_LIMIT, $site_limit, false );

			if ( ! empty( $email ) ) {
				update_option( self::OPT_EMAIL, sanitize_email( $email ), false );
			}

			// Force immediate quota sync so the dashboard reflects any usage
			// already accumulated against this license on other servers.
			self::sync_remote_usage( true );

			return [
				'success' => true,
				'plan'    => $plan,
				'message' => sprintf(
					/* translators: %s: plan name */
					__( 'License activated successfully. Plan: %s', 'contentosapp-studio' ),
					strtoupper( $plan )
				),
			];
		}

		$error_messages = [
			'expired'              => __( 'This license has expired. Please renew it at contentosapp.com.', 'contentosapp-studio' ),
			'disabled'             => __( 'This license has been disabled. Please contact support.', 'contentosapp-studio' ),
			'invalid'              => __( 'License key not found. Please check and try again.', 'contentosapp-studio' ),
			'item_name_mismatch'   => __( 'License key not found. Please check and try again.', 'contentosapp-studio' ),
			'no_activations_left'  => __( 'This license has reached its activation limit. Deactivate it on another site first.', 'contentosapp-studio' ),
		];

		$message = $error_messages[ $status ] ?? sprintf(
			/* translators: %s: status returned by the server */
			__( 'Activation failed (status: %s). Please contact support.', 'contentosapp-studio' ),
			$status
		);

		return [
			'success' => false,
			'plan'    => self::PLAN_FREE,
			'message' => $message,
		];
	}

	// ── Internal: remote request ──────────────────────────────────────────────

	/**
	 * HTTP request to EDD Software Licensing on contentosapp.com.
	 *
	 * @param  string $action       activate_license | deactivate_license | check_license
	 * @param  string $license_key
	 * @return array|WP_Error
	 */
	private static function remote_request( string $action, string $license_key ) {
		$url = add_query_arg( [
			'edd_action' => $action,
			'license'    => $license_key,
			'url'        => self::service_site_url(),
		], self::STORE_URL );

		$response = wp_safe_remote_get( $url, costudio_safe_remote_args( [
			'timeout'    => 15,
			'sslverify'  => true,
			'user-agent' => self::BROWSER_UA,
		] ) );

		if ( is_wp_error( $response ) ) {
			return new WP_Error(
				'connection_failed',
				__( 'Could not connect to the license server. Please check your internet connection and try again.', 'contentosapp-studio' )
			);
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		$raw  = wp_remote_retrieve_body( $response );
		$body = json_decode( $raw, true );

		if ( $code !== 200 || ! is_array( $body ) ) {
			costudio_log_debug( 'license.remote_request.path_a_error', [
				'http' => $code,
				'body' => $raw,
			] );
			return new WP_Error(
				'server_error',
				__( 'License server returned an unexpected response. Please try again later.', 'contentosapp-studio' )
			);
		}

		return $body;
	}

	/**
	 * HTTP request to admin-ajax.php on contentosapp.com (Bypass for WAF/REST blocks).
	 *
	 * @param  string $action      The ajax action (e.g. cap_api_quota)
	 * @param  string $license_key
	 * @return array|WP_Error
	 */
	private static function remote_ajax_request( string $action, string $license_key, array $body = [] ) {
		costudio_log_debug( 'license.remote_ajax_request.start', [ 'action' => $action ] );
		$url = costudio_api_ajax_url( $action );

		// For maximum compatibility with WAFs, we use standard POST parameters instead of a JSON body.
		// However, we still need a string to sign. We'll sign the query string of the body parameters.
		$post_data = $body;
		if ( ! isset( $post_data['site_url'] ) ) {
			$post_data['site_url'] = self::service_site_url();
		}
		$body_str  = ! empty( $post_data ) ? http_build_query( $post_data ) : '';

		$args = [
			'timeout'    => 15,
			'sslverify'  => true,
			'user-agent' => self::BROWSER_UA,
			'headers'    => [
				'Accept'          => 'application/json',
			],
		];

		if ( '' !== $license_key ) {
			$ts     = time();
			$secret = self::get_signing_secret( $license_key );
			$hash   = hash_hmac( 'sha256', $ts . '.' . $body_str, $secret );

			$args['headers']['Authorization']   = 'Bearer ' . $license_key;
			$args['headers']['X-FOS-Signature'] = $ts . '.' . $hash;
		}

		if ( ! empty( $post_data ) ) {
			$args['method'] = 'POST';
			$args['body']   = $post_data;
			// Note: wp_remote_post with an array body automatically sets Content-Type to application/x-www-form-urlencoded
			$response = wp_safe_remote_post( $url, costudio_safe_remote_args( $args ) );
		} else {
			$response = wp_safe_remote_get( $url, costudio_safe_remote_args( $args ) );
		}

		costudio_log_debug( 'license.remote_ajax_request.sent', [
			'action' => $action,
			'url'    => $url,
		] );

		if ( is_wp_error( $response ) ) {
			return $response;
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		$raw  = wp_remote_retrieve_body( $response );
		$body = json_decode( $raw, true );

		if ( $code !== 200 || ! is_array( $body ) ) {
			costudio_log_debug( 'license.remote_ajax_request.error', [
				'action' => $action,
				'http'   => $code,
				'body'   => $raw,
			] );
			
			$error_msg = '';
			if ( is_array( $body ) ) {
				if ( isset( $body['data']['message'] ) ) {
					$error_msg = $body['data']['message'];
				} elseif ( isset( $body['error']['message'] ) ) {
					$error_msg = $body['error']['message'];
				} elseif ( isset( $body['message'] ) ) {
					$error_msg = $body['message'];
				} elseif ( isset( $body['data'] ) && is_string( $body['data'] ) ) {
					$error_msg = $body['data'];
				} elseif ( isset( $body['error'] ) && is_string( $body['error'] ) ) {
					$error_msg = $body['error'];
				}
			}

			$msg = ! empty( $error_msg ) ? $error_msg : 'AJAX fallback failed (HTTP ' . $code . ')';
			return new WP_Error( 'ajax_failed', $msg );
		}

		return $body;
	}

	/**
	 * JSON-body sibling of remote_ajax_request().
	 *
	 * Same dual-path admin-ajax fallback, but sends a JSON body and signs
	 * the raw JSON string (not http_build_query). Used by checkout creation
	 * because the server's handle_addons_checkout reads $request->get_json_params(),
	 * which doesn't fire on form-urlencoded bodies.
	 *
	 * Error envelope handling: server emits { error: { code, message } } on
	 * 4xx/5xx (CAP_Routes::error_response shape), so we surface those fields
	 * directly into the WP_Error code/message — callers can branch on
	 * specific codes like 'rate_limited', 'invalid_package', 'plan_below_minimum'.
	 *
	 * @param  string $action      admin-ajax action slug.
	 * @param  string $license_key
	 * @param  array  $body        Will be JSON-encoded.
	 * @return array|WP_Error
	 */
	private static function remote_ajax_json_request( string $action, string $license_key, array $body ) {
		$url = costudio_api_ajax_url( $action );

		if ( ! isset( $body['site_url'] ) ) {
			$body['site_url'] = self::service_site_url();
		}

		$body_str = wp_json_encode( $body );
		$ts       = time();
		$secret   = self::get_signing_secret( $license_key );
		$hash     = hash_hmac( 'sha256', $ts . '.' . $body_str, $secret );

		$args = [
			'timeout'    => 15,
			'sslverify'  => true,
			'user-agent' => self::BROWSER_UA,
			'method'     => 'POST',
			'headers'    => [
				'Authorization'   => 'Bearer ' . $license_key,
				'X-FOS-Signature' => $ts . '.' . $hash,
				'Content-Type'    => 'application/json',
				'Accept'          => 'application/json',
			],
			'body'       => $body_str,
		];

		$response = wp_safe_remote_post( $url, costudio_safe_remote_args( $args ) );

		if ( is_wp_error( $response ) ) {
			return $response;
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		$raw  = wp_remote_retrieve_body( $response );
		$decoded = json_decode( $raw, true );

		if ( $code === 200 && is_array( $decoded ) ) {
			return $decoded;
		}

		// Pass server's error code/message through so JS can react specifically
		// (e.g. rate_limited → "try again in 5min", invalid_package → reload modal).
		$error_code = 'ajax_failed';
		$error_msg  = 'AJAX request failed (HTTP ' . $code . ')';
		if ( is_array( $decoded ) && isset( $decoded['error'] ) && is_array( $decoded['error'] ) ) {
			$error_code = (string) ( $decoded['error']['code']    ?? $error_code );
			$error_msg  = (string) ( $decoded['error']['message'] ?? $error_msg );
		} elseif ( is_array( $decoded ) && isset( $decoded['data']['message'] ) ) {
			$error_msg = (string) $decoded['data']['message'];
		} elseif ( is_array( $decoded ) && isset( $decoded['message'] ) ) {
			$error_msg = (string) $decoded['message'];
		} elseif ( is_array( $decoded ) && isset( $decoded['data'] ) && is_string( $decoded['data'] ) ) {
			$error_msg = $decoded['data'];
		}

		return new WP_Error( $error_code, $error_msg, [ 'status' => $code ] );
	}

	// ── Internal: helpers ─────────────────────────────────────────────────────

	/**
	 * Derive plan from EDD SL response data.
	 * Prefer the explicit server plan; only fall back to legacy activation limits.
	 */
	private static function plan_from_license_data( array $data ): string {
		$raw_plan = strtolower( trim( (string) ( $data['plan'] ?? '' ) ) );
		$plan     = self::normalize_plan( $raw_plan );
		if ( self::PLAN_FREE !== $plan || self::PLAN_FREE === $raw_plan ) {
			return $plan;
		}

		$item = strtolower( (string) ( $data['item_name'] ?? '' ) );
		if ( false !== strpos( $item, 'studio' ) )  return self::PLAN_STUDIO;
		if ( false !== strpos( $item, 'starter' ) ) return self::PLAN_STARTER;
		if ( false !== strpos( $item, 'pro' ) )     return self::PLAN_PRO;
		if ( false !== strpos( $item, 'free' ) )    return self::PLAN_FREE;

		$license_limit = (int) ( $data['license_limit'] ?? 0 );
		if ( 100 === $license_limit || 10 === $license_limit || 15 === $license_limit ) return self::PLAN_STUDIO;
		if ( 30 === $license_limit || 3 === $license_limit ) return self::PLAN_PRO;
		if ( 5 === $license_limit ) return self::PLAN_STARTER; // site_limit=1 é ambíguo (free também) → cai no default seguro `free` abaixo.

		if ( isset( $data['limit'] ) ) {
			$quota_limit = (int) $data['limit'];
			if ( 100 === $quota_limit ) return self::PLAN_STUDIO;
			if ( 30 === $quota_limit )  return self::PLAN_PRO;
			if ( 5 === $quota_limit )   return self::PLAN_STARTER;
			if ( 3 === $quota_limit )   return self::PLAN_FREE;
		}

		return self::PLAN_FREE;
	}

	private static function site_limit_from_license_data( array $data, string $plan ): int {
		$site_limit = (int) ( $data['site_limit'] ?? 0 );
		if ( $site_limit > 0 ) {
			return $site_limit;
		}

		return self::service_site_limit_for_plan( $plan );
	}

	private static function service_site_url(): string {
		$url = function_exists( 'get_site_url' ) ? (string) get_site_url() : (string) home_url();
		return rtrim( $url, '/' );
	}

	private static function is_fos2_key( string $key ): bool {
		return strpos( $key, self::KEY_PREFIX . '.' ) === 0;
	}

	/**
	 * Hooked to costudio_production_completed and costudio_production_failed.
	 * Invalidates the quota transient so the next dashboard load shows fresh data.
	 *
	 * @internal
	 */
	public static function on_production_change(): void {
		// Ao finalizar ou falhar uma produção, forçamos um sync com a API.
		// Como isso roda em background (fim do pipeline), o impacto é zero no browser do user,
		// e garante que a "soberania" da API seja atualizada instantaneamente.
		self::sync_remote_usage( true );
	}
}
