<?php
// exit if accessed directly
if ( ! defined( 'ABSPATH' ) )
	exit;

/**
 * Cookie_Notice_React_Admin_Ajax class.
 *
 * Provides the PHP AJAX backend for the React admin UI.
 * Registers six wp_ajax_ actions consumed by the React admin bundle.
 *
 * @class   Cookie_Notice_React_Admin_Ajax
 * @package Cookie_Notice
 */
class Cookie_Notice_React_Admin_Ajax {

	/**
	 * Sentinel prefix for WAF-safe base64-encoded save fields.
	 *
	 * The React admin base64-encodes code/markup-bearing fields (refuse_code,
	 * refuse_code_head, conditional_rules) and prefixes this marker so a WAF
	 * (WordFence &c.) can't 403 the POST for carrying a raw <script>.
	 *
	 * The marker is PRINTABLE ASCII containing none of the characters
	 * wp_magic_quotes() (addslashes) escapes on $_POST — no NUL, no single/
	 * double quote, no backslash. That is load-bearing: WordPress runs
	 * wp_magic_quotes() on every request BEFORE admin-ajax dispatch, so by the
	 * time save_options() reads $_POST the value is already addslashes()'d. A
	 * slash-safe marker survives that untouched, so the raw-value strncmp
	 * detection below still matches. (An earlier "\x00CNB64\x00" marker was
	 * silently broken: addslashes turns NUL into backslash-zero, so the leading
	 * bytes no longer matched and every encoded save fell to the passthrough
	 * branch — storing the literal marker+base64 text instead of the script.)
	 *
	 * It also can't begin a real pasted value: a legacy payload is a <script…>,
	 * an <!-- comment -->, or a JSON array "[…]" — none start with "--", so a
	 * non-sentinel value is unambiguously a legacy (non-encoded) payload.
	 *
	 * MUST match the JS side byte-for-byte:
	 * WAF_B64_SENTINEL in src/admin-react/api/index.js ("--CNWAF-B64--").
	 */
	const WAF_B64_SENTINEL = "--CNWAF-B64--";

	/**
	 * Class constructor.
	 *
	 * @return void
	 */
	public function __construct() {
		// Read-only hooks — always available regardless of ui_mode.
		add_action( 'wp_ajax_cn_react_dashboard',           [ $this, 'get_dashboard' ] );
		add_action( 'wp_ajax_cn_react_config',              [ $this, 'get_config' ] );
		add_action( 'wp_ajax_cn_react_consent_logs',        [ $this, 'get_consent_logs' ] );
		add_action( 'wp_ajax_cn_react_export_consent_logs', [ $this, 'export_consent_logs' ] );
		add_action( 'wp_ajax_cn_get_api_environment',         [ $this, 'get_api_environment' ] );

		// Write hooks — only register when ui_mode is "react" (#2267).
		// In legacy mode the PHP form path handles writes; registering these
		// would allow stale React JS (cached by a CDN or browser) to race
		// against the legacy form submit.
		$ui_mode = Cookie_Notice()->options['general']['ui_mode'] ?? 'legacy';

		if ( $ui_mode === 'react' ) {
			add_action( 'wp_ajax_cn_react_script_update',       [ $this, 'update_script' ] );
			add_action( 'wp_ajax_cn_react_save_options',        [ $this, 'save_options' ] );
			add_action( 'wp_ajax_cn_react_rescan_scripts',      [ $this, 'rescan_scripts' ] );
			add_action( 'wp_ajax_cn_react_rule_values',         [ $this, 'get_rule_values' ] );
		}

		// Mode-agnostic state hooks — welcome dismissal and setup wizard
		// completion must work regardless of ui_mode.
		add_action( 'wp_ajax_cn_react_dismiss_welcome',        [ $this, 'dismiss_welcome' ] );
		add_action( 'wp_ajax_cn_react_complete_setup_wizard', [ $this, 'complete_setup_wizard' ] );

		// Dev harness only — CN_DEV_MODE is NOT an environment switch (it does not control
		// which API environment the plugin targets). Use CN_APP_HOST_URL, CN_APP_WIDGET_URL,
		// CN_ACCOUNT_API_URL etc. for that. CN_DEV_MODE enables developer-only UI tooling
		// (usage override, dev_reset) and should never be set on production or staging servers.
		if ( defined( 'CN_DEV_MODE' ) && CN_DEV_MODE ) {
			add_action( 'wp_ajax_cn_react_dev_reset',        [ $this, 'dev_reset' ] );
			add_action( 'wp_ajax_cn_react_test_set_option',  [ $this, 'test_set_option' ] );
			add_action( 'wp_ajax_cn_react_test_get_option',  [ $this, 'test_get_option' ] );
		}

	}

	/**
	 * Verify request nonce and capability.
	 *
	 * Sends a JSON error and exits when the check fails, so handlers can call
	 * this at the top without needing to check the return value.
	 *
	 * @return void
	 */
	private function verify_request() {
		check_ajax_referer( 'cn_react_nonce', 'nonce' );

		if ( ! current_user_can( apply_filters( 'cn_manage_cookie_notice_cap', 'manage_options' ) ) ) {
			wp_send_json_error( [ 'error' => 'Insufficient permissions.' ] );
		}
	}

	/**
	 * Return dashboard data for the Protection tab.
	 *
	 * @return void
	 */
	public function get_dashboard() {
		$this->verify_request();

		$cn = Cookie_Notice();

		// --- Read cached analytics option ---
		// Single source: cookie_notice_app_analytics (refreshed hourly via welcome-api.php cron).
		// ⚠️ Multisite pattern: use site_option ONLY when network-active with global_override.
		// Do NOT simplify to is_multisite() alone — pattern matches welcome-api.php get_app_config().
		$network       = $cn->is_network_options();
		$analytics_raw = $network
			? get_site_option( 'cookie_notice_app_analytics', [] )
			: get_option( 'cookie_notice_app_analytics', [] );

		// --- Cycle usage (visits vs threshold) ---
		// Read from cached analytics option; CN_DEV_MODE overrides for UI testing.
		$visits    = ! empty( $analytics_raw['cycleUsage']->visits ) ? (int) $analytics_raw['cycleUsage']->visits : 0;
		$threshold = ! empty( $analytics_raw['cycleUsage']->threshold ) ? (int) $analytics_raw['cycleUsage']->threshold : 0;

		// CN_DEV_MODE: honour cn_usage=0-100 (forwarded as POST field by fetchDashboard
		// since admin-ajax.php is a POST endpoint and $_GET params from the page URL
		// are not available here).
		if ( defined( 'CN_DEV_MODE' ) && CN_DEV_MODE && isset( $_POST['cn_usage'] ) ) {
			$pct       = max( 0, min( 100, (int) $_POST['cn_usage'] ) );
			$threshold = $threshold > 0 ? $threshold : 1000;
			$visits    = (int) round( $threshold * ( $pct / 100 ) );
		}

		// --- ConsentStats breakdown ---

		$level_totals = [ 1 => 0, 2 => 0, 3 => 0 ];

		if ( ! empty( $analytics_raw['consentActivities'] ) && is_array( $analytics_raw['consentActivities'] ) ) {
			foreach ( $analytics_raw['consentActivities'] as $entry ) {
				$lvl = (int) $entry->consentlevel;
				if ( isset( $level_totals[ $lvl ] ) ) {
					$level_totals[ $lvl ] += (int) $entry->totalrecd;
				}
			}
		}

		$consent_breakdown = $this->compute_consent_breakdown( $level_totals );

		// Regulations saved locally by cn_api_request?configure action.
		// Exposed here so Protection.jsx LAWS card can display them without a
		// Designer API round-trip. (#1897)
		$reg_keys     = $network
			? get_site_option( 'cookie_notice_app_regulations', [] )
			: get_option( 'cookie_notice_app_regulations', [] );
		$regulations  = array_fill_keys( (array) $reg_keys, true );

		// Language codes saved locally by react_apply_languages() on successful API write. (#1966)
		// Always includes 'en' (default) + any additional codes the user configured.
		$saved_languages = $network
			? get_site_option( 'cookie_notice_app_languages', [] )
			: get_option( 'cookie_notice_app_languages', [] );
		$language = array_values( array_unique( array_merge( [ 'en' ], (array) $saved_languages ) ) );

		// Platform account email from login token (#2168).
		// Stored in cookie_notice_app_token transient as ->email after successful login.
		// Used in PortalBridgeModal to tell the user which email to sign in with.
		// Returns empty string when not connected (token not set or expired).
		$data_token    = $network
			? get_site_transient( 'cookie_notice_app_token' )
			: get_transient( 'cookie_notice_app_token' );
		$account_email = ! empty( $data_token->email ) ? sanitize_email( $data_token->email ) : '';

		// Banner design fields cached by get_app_config() — React computes
		// the active template on the fly by matching against PRESETS.
		$design = $network
			? get_site_option( 'cookie_notice_app_design', [] )
			: get_option( 'cookie_notice_app_design', [] );

		wp_send_json_success( [
			'analytics'        => [
				'cycleUsage' => [
					'visits'    => $visits,
					'threshold' => $threshold,
				],
			],
			'consentBreakdown' => $consent_breakdown,
			'domainUrl'        => home_url(),
			'appId'            => $cn->options['general']['app_id'],
			'activatedAt'      => isset( $cn->status_data['activation_datetime'] ) ? $cn->status_data['activation_datetime'] : 0,
			'consentCount'     => $consent_breakdown['total'],
			'accountEmail'     => $account_email,
			'appConfig'        => [
				'regulations' => $regulations,
				'language'    => $language,
				'design'      => $design,
			],
		] );
	}

	/**
	 * Return blocking/consent configuration data.
	 *
	 * Reads the cached Designer API config from the cookie_notice_app_blocking WP option
	 * (populated by welcome-api.php get_app_config() on admin page load, on the
	 * 24h cron, or via "Pull Configuration" button). Falls back to an empty stub
	 * for new installs.
	 *
	 * @return void
	 */
	public function get_config() {
		$this->verify_request();

		$cn = Cookie_Notice();

		// ⚠️ Same multisite pattern as get_dashboard() — see comment there.
		$network  = $cn->is_network_options();
		$blocking = $network
			? get_site_option( 'cookie_notice_app_blocking', [] )
			: get_option( 'cookie_notice_app_blocking', [] );

		wp_send_json_success( $this->build_blocking_response( $blocking ) );
	}

	/**
	 * Return paginated consent log entries.
	 *
	 * Calls the Transactional API via welcome-api.php for the requested date,
	 * maps each record to the shape expected by ConsentLogTable.jsx, then
	 * applies in-PHP pagination (10 records per page).
	 *
	 * POST params accepted:
	 *   page       int     Page number (1-based, default 1)
	 *   start_date string  Date to fetch logs for (Y-m-d, default today)
	 *   sort       string  Sort column key (ignored server-side — API returns ordered data)
	 *   order      string  'asc' | 'desc' (ignored server-side)
	 *
	 * @return void
	 */
	public function get_consent_logs() {
		$this->verify_request();

		$page       = isset( $_POST['page'] ) ? max( 1, absint( $_POST['page'] ) ) : 1;
		$start_date = isset( $_POST['start_date'] ) ? sanitize_text_field( $_POST['start_date'] ) : date( 'Y-m-d' );
		$end_date   = isset( $_POST['end_date'] ) ? sanitize_text_field( $_POST['end_date'] ) : $start_date;
		$per_page   = 10;

		// Validate date formats (Y-m-d).
		$dt = DateTime::createFromFormat( 'Y-m-d', $start_date );
		if ( ! $dt || $dt->format( 'Y-m-d' ) !== $start_date ) {
			$start_date = date( 'Y-m-d' );
		}

		$dt_end = DateTime::createFromFormat( 'Y-m-d', $end_date );
		if ( ! $dt_end || $dt_end->format( 'Y-m-d' ) !== $end_date || $end_date < $start_date ) {
			$end_date = $start_date;
		}

		$cn = Cookie_Notice();

		// Server-side range cap — free = 7 days, pro = 90 days.
		$max_range = ( $cn->get_subscription() === 'pro' ) ? 90 : 7;
		$range     = (int) ( ( new DateTime( $end_date ) )->diff( new DateTime( $start_date ) )->days );

		if ( $range > $max_range ) {
			$end_date = ( new DateTime( $start_date ) )->modify( "+{$max_range} days" )->format( 'Y-m-d' );
		}

		$empty_breakdown = [ 'total' => 0, 'acceptRate' => 0, 'customRate' => 0, 'rejectRate' => 0, 'levelLabels' => $this->get_level_labels() ];

		// No app_id means not connected — return empty gracefully.
		if ( empty( $cn->options['general']['app_id'] ) ) {
			wp_send_json_success( [
				'logs'             => [],
				'total'            => 0,
				'page'             => $page,
				'totalPages'       => 0,
				'consentBreakdown' => $empty_breakdown,
			] );
			return;
		}

		// Single API call for the full date range (Transactional API handles range via EndDate).
		$raw = $cn->welcome_api->get_cookie_consent_logs( $start_date, $end_date );

		if ( ! is_array( $raw ) || empty( $raw ) ) {
			wp_send_json_success( [
				'logs'             => [],
				'total'            => 0,
				'page'             => $page,
				'totalPages'       => 0,
				'consentBreakdown' => $empty_breakdown,
			] );
			return;
		}

		// Transform raw API records into UI-ready log entries.
		$result = $this->transform_consent_logs( $raw, $cn );
		$logs   = $result['logs'];

		$total      = count( $logs );
		$total_pages = (int) ceil( $total / $per_page );
		$offset     = ( $page - 1 ) * $per_page;
		$paged      = array_slice( $logs, $offset, $per_page );

		wp_send_json_success( [
			'logs'             => $paged,
			'total'            => $total,
			'page'             => $page,
			'totalPages'       => $total_pages,
			'consentBreakdown' => $result['consent_breakdown'],
		] );
	}

	/**
	 * Add, edit, or remove a script provider.
	 *
	 * For 'edit' operations, updates the provider's CategoryID and propagates
	 * the change to all patterns belonging to that provider.
	 *
	 * @return void
	 */
	public function update_script() {
		$this->verify_request();

		$operation = isset( $_POST['operation'] ) ? sanitize_text_field( $_POST['operation'] ) : '';

		if ( ! in_array( $operation, [ 'add', 'edit', 'remove' ], true ) ) {
			wp_send_json_error( [ 'error' => 'Invalid operation.' ] );
		}

		if ( $operation === 'edit' ) {
			$provider_id = isset( $_POST['provider_id'] ) ? sanitize_text_field( $_POST['provider_id'] ) : '';
			$category_id = isset( $_POST['category_id'] ) ? absint( $_POST['category_id'] ) : 0;

			if ( empty( $provider_id ) ) {
				wp_send_json_error( [ 'error' => 'Missing provider_id.' ] );
			}

			if ( ! in_array( $category_id, [ 1, 2, 3, 4 ], true ) ) {
				wp_send_json_error( [ 'error' => 'Invalid category_id.' ] );
			}

			$cn      = Cookie_Notice();
			$network = $cn->is_network_options();

			$blocking = $network
				? get_site_option( 'cookie_notice_app_blocking', [] )
				: get_option( 'cookie_notice_app_blocking', [] );

			if ( empty( $blocking ) || ! isset( $blocking['providers'] ) ) {
				wp_send_json_error( [ 'error' => 'No blocking configuration found.' ] );
			}

			// Update the provider's CategoryID.
			$found = false;

			foreach ( $blocking['providers'] as &$provider ) {
				$pid = is_object( $provider ) ? $provider->ProviderID : ( isset( $provider['ProviderID'] ) ? $provider['ProviderID'] : '' );

				if ( (string) $pid === (string) $provider_id ) {
					if ( is_object( $provider ) ) {
						$provider->CategoryID = $category_id;
					} else {
						$provider['CategoryID'] = $category_id;
					}
					$found = true;
					break;
				}
			}
			unset( $provider );

			if ( ! $found ) {
				wp_send_json_error( [ 'error' => 'Provider not found.' ] );
			}

			// Propagate CategoryID to all patterns belonging to this provider.
			if ( isset( $blocking['patterns'] ) && is_array( $blocking['patterns'] ) ) {
				foreach ( $blocking['patterns'] as &$pattern ) {
					$pat_pid = is_object( $pattern ) ? $pattern->ProviderID : ( isset( $pattern['ProviderID'] ) ? $pattern['ProviderID'] : '' );

					if ( (string) $pat_pid === (string) $provider_id ) {
						if ( is_object( $pattern ) ) {
							$pattern->CategoryID = $category_id;
						} else {
							$pattern['CategoryID'] = $category_id;
						}
					}
				}
				unset( $pattern );
			}

			// Save back.
			if ( $network ) {
				update_site_option( 'cookie_notice_app_blocking', $blocking );
			} else {
				update_option( 'cookie_notice_app_blocking', $blocking );
			}
		}

		if ( $operation === 'add' ) {
			$provider_name   = isset( $_POST['provider_name'] ) ? sanitize_text_field( $_POST['provider_name'] ) : '';
			$provider_url    = isset( $_POST['provider_url'] )  ? esc_url_raw( $_POST['provider_url'] )           : '';
			$category_id     = isset( $_POST['category_id'] )   ? absint( $_POST['category_id'] )                  : 0;
			$description     = isset( $_POST['description'] )   ? sanitize_text_field( $_POST['description'] )     : '';
			$script_patterns = isset( $_POST['script_patterns'] ) && is_array( $_POST['script_patterns'] ) ? $_POST['script_patterns'] : [];
			$iframe_patterns = isset( $_POST['iframe_patterns'] ) && is_array( $_POST['iframe_patterns'] ) ? $_POST['iframe_patterns'] : [];

			if ( empty( $provider_name ) ) {
				wp_send_json_error( [ 'error' => 'Provider name is required.' ] );
			}

			if ( ! in_array( $category_id, [ 1, 2, 3, 4 ], true ) ) {
				wp_send_json_error( [ 'error' => 'Invalid category_id.' ] );
			}

			$cn      = Cookie_Notice();
			$network = $cn->is_network_options();

			$blocking = $network
				? get_site_option( 'cookie_notice_app_blocking', [] )
				: get_option( 'cookie_notice_app_blocking', [] );

			if ( ! is_array( $blocking ) ) {
				$blocking = [];
			}
			if ( ! isset( $blocking['providers'] ) ) {
				$blocking['providers'] = [];
			}
			if ( ! isset( $blocking['patterns'] ) ) {
				$blocking['patterns'] = [];
			}

			// Generate a unique provider ID from the name + timestamp.
			$provider_id = 'custom-' . sanitize_title( $provider_name ) . '-' . time();

			// Append the new provider.
			$blocking['providers'][] = (object) [
				'ProviderID'   => $provider_id,
				'ProviderName' => $provider_name,
				'ProviderURL'  => $provider_url,
				'CategoryID'   => $category_id,
				'IsCustom'     => true,
			];

			// Find current max CookieID so new patterns get unique IDs.
			$max_cookie_id = 0;
			foreach ( $blocking['patterns'] as $p ) {
				$cid = is_object( $p ) ? (int) $p->CookieID : (int) ( isset( $p['CookieID'] ) ? $p['CookieID'] : 0 );
				if ( $cid > $max_cookie_id ) {
					$max_cookie_id = $cid;
				}
			}

			// Append script patterns.
			foreach ( $script_patterns as $pattern_str ) {
				$pattern_str = sanitize_text_field( stripslashes( $pattern_str ) );
				if ( empty( $pattern_str ) ) {
					continue;
				}
				$max_cookie_id++;
				$blocking['patterns'][] = (object) [
					'CookieID'      => $max_cookie_id,
					'ProviderID'    => $provider_id,
					'CategoryID'    => $category_id,
					'PatternType'   => 'script',
					'PatternFormat' => 'wildcard',
					'Pattern'       => $pattern_str,
				];
			}

			// Append iframe patterns.
			foreach ( $iframe_patterns as $pattern_str ) {
				$pattern_str = sanitize_text_field( stripslashes( $pattern_str ) );
				if ( empty( $pattern_str ) ) {
					continue;
				}
				$max_cookie_id++;
				$blocking['patterns'][] = (object) [
					'CookieID'      => $max_cookie_id,
					'ProviderID'    => $provider_id,
					'CategoryID'    => $category_id,
					'PatternType'   => 'iframe',
					'PatternFormat' => 'wildcard',
					'Pattern'       => $pattern_str,
				];
			}

			if ( $network ) {
				update_site_option( 'cookie_notice_app_blocking', $blocking );
			} else {
				update_option( 'cookie_notice_app_blocking', $blocking );
			}

			wp_send_json_success( [
				'message'     => 'Script provider added.',
				'provider_id' => $provider_id,
			] );
		}

		if ( $operation === 'remove' ) {
			$provider_id = isset( $_POST['provider_id'] ) ? sanitize_text_field( $_POST['provider_id'] ) : '';

			if ( empty( $provider_id ) ) {
				wp_send_json_error( [ 'error' => 'Missing provider_id.' ] );
			}

			$cn      = Cookie_Notice();
			$network = $cn->is_network_options();

			$blocking = $network
				? get_site_option( 'cookie_notice_app_blocking', [] )
				: get_option( 'cookie_notice_app_blocking', [] );

			if ( empty( $blocking ) || ! isset( $blocking['providers'] ) ) {
				wp_send_json_error( [ 'error' => 'No blocking configuration found.' ] );
			}

			// Remove the provider entry.
			$blocking['providers'] = array_values( array_filter( $blocking['providers'], function( $p ) use ( $provider_id ) {
				$pid = is_object( $p ) ? $p->ProviderID : ( isset( $p['ProviderID'] ) ? $p['ProviderID'] : '' );
				return (string) $pid !== (string) $provider_id;
			} ) );

			// Remove all patterns belonging to this provider.
			if ( isset( $blocking['patterns'] ) && is_array( $blocking['patterns'] ) ) {
				$blocking['patterns'] = array_values( array_filter( $blocking['patterns'], function( $p ) use ( $provider_id ) {
					$pid = is_object( $p ) ? $p->ProviderID : ( isset( $p['ProviderID'] ) ? $p['ProviderID'] : '' );
					return (string) $pid !== (string) $provider_id;
				} ) );
			}

			if ( $network ) {
				update_site_option( 'cookie_notice_app_blocking', $blocking );
			} else {
				update_option( 'cookie_notice_app_blocking', $blocking );
			}

			wp_send_json_success( [ 'message' => 'Script provider removed.' ] );
		}

		wp_send_json_success( [ 'message' => 'Script provider updated.' ] );
	}

	/**
	 * Transform raw API consent log records into structured log entries.
	 *
	 * Shared by get_consent_logs() (paginated table) and export_consent_logs() (CSV).
	 * Returns both the transformed log entries and the consent breakdown stats.
	 *
	 * @param array              $raw Raw records from the Transactional API.
	 * @param Cookie_Notice_Main $cn  Plugin instance.
	 * @return array { 'logs' => array, 'consent_breakdown' => array }
	 */
	private function transform_consent_logs( $raw, $cn ) {
		// Compute consent breakdown from real-time data.
		$level_counts = [ 1 => 0, 2 => 0, 3 => 0 ];

		foreach ( $raw as $record ) {
			$lvl = isset( $record->ev_consentlevel ) ? (int) $record->ev_consentlevel : 0;
			if ( isset( $level_counts[ $lvl ] ) ) {
				$level_counts[ $lvl ]++;
			}
		}

		$consent_breakdown = $this->compute_consent_breakdown( $level_counts );

		// Consent level integer → human label (matches ConsentLogTable pill styles).
		$labels = $this->get_level_labels();
		$level_map = [
			1 => $labels['level1'],
			2 => $labels['level2'],
			3 => $labels['level3'],
		];

		$logs = [];

		foreach ( $raw as $record ) {
			$categories = [];

			if ( ! empty( $record->ev_essential ) )
				$categories[] = 'Essential';

			if ( ! empty( $record->ev_analytics ) )
				$categories[] = 'Analytics';

			if ( ! empty( $record->ev_marketing ) )
				$categories[] = 'Marketing';

			if ( ! empty( $record->ev_functional ) )
				$categories[] = 'Functional';

			$level = isset( $record->ev_consentlevel ) ? (int) $record->ev_consentlevel : 0;

			// Format timestamp to readable date/time.
			$date_str = '';
			if ( ! empty( $record->timestamp ) ) {
				try {
					$ts       = new DateTime( $record->timestamp );
					$date_str = $ts->format( 'Y-m-d H:i' ) . ' GMT';
				} catch ( Exception $e ) {
					$date_str = $record->timestamp;
				}
			}

			$logs[] = [
				'id'         => isset( $record->ev_eventdetails_consentid ) ? $record->ev_eventdetails_consentid : '',
				'level'      => isset( $level_map[ $level ] ) ? $level_map[ $level ] : $labels['level2'],
				'levelNum'   => $level,
				'categories' => $categories,
				'date'       => $date_str,
				'ip'         => isset( $record->rj_ip ) ? $record->rj_ip : '',
			];
		}

		return [
			'logs'              => $logs,
			'consent_breakdown' => $consent_breakdown,
		];
	}

	/**
	 * Export consent logs as a downloadable CSV.
	 *
	 * Reuses transform_consent_logs() for data transformation, then formats
	 * the result as CSV and returns it as a string for browser download.
	 * Pro-only: enforced server-side (client-side TierGate is not sufficient).
	 *
	 * POST params accepted:
	 *   start_date string  Range start (Y-m-d, default today)
	 *   end_date   string  Range end   (Y-m-d, default start_date)
	 *
	 * @return void
	 */
	public function export_consent_logs() {
		$this->verify_request();

		$cn = Cookie_Notice();

		// Server-side Pro gate — TierGate in React is client-only.
		if ( $cn->get_subscription() !== 'pro' ) {
			wp_send_json_error( [ 'error' => 'CSV export requires a Pro subscription.' ] );
			return;
		}

		$start_date = isset( $_POST['start_date'] ) ? sanitize_text_field( $_POST['start_date'] ) : date( 'Y-m-d' );
		$end_date   = isset( $_POST['end_date'] ) ? sanitize_text_field( $_POST['end_date'] ) : $start_date;

		// Validate date formats (Y-m-d).
		$dt = DateTime::createFromFormat( 'Y-m-d', $start_date );
		if ( ! $dt || $dt->format( 'Y-m-d' ) !== $start_date ) {
			$start_date = date( 'Y-m-d' );
		}

		$dt_end = DateTime::createFromFormat( 'Y-m-d', $end_date );
		if ( ! $dt_end || $dt_end->format( 'Y-m-d' ) !== $end_date || $end_date < $start_date ) {
			$end_date = $start_date;
		}

		// Server-side range cap — Pro = 90 days.
		$range = (int) ( ( new DateTime( $end_date ) )->diff( new DateTime( $start_date ) )->days );

		if ( $range > 90 ) {
			$end_date = ( new DateTime( $start_date ) )->modify( '+90 days' )->format( 'Y-m-d' );
		}

		// No app_id means not connected — return empty.
		if ( empty( $cn->options['general']['app_id'] ) ) {
			wp_send_json_success( [ 'csv' => '', 'count' => 0 ] );
			return;
		}

		$raw = $cn->welcome_api->get_cookie_consent_logs( $start_date, $end_date );

		if ( ! is_array( $raw ) || empty( $raw ) ) {
			wp_send_json_success( [ 'csv' => '', 'count' => 0 ] );
			return;
		}

		$result = $this->transform_consent_logs( $raw, $cn );
		$logs   = $result['logs'];

		// Build CSV string.
		$csv_lines   = [];
		$csv_lines[] = 'Consent ID,Level,Date,IP,Categories';

		foreach ( $logs as $log ) {
			$csv_lines[] = sprintf(
				'"%s","%s","%s","%s","%s"',
				str_replace( '"', '""', $log['id'] ),
				str_replace( '"', '""', $log['level'] ),
				str_replace( '"', '""', $log['date'] ),
				str_replace( '"', '""', $log['ip'] ),
				str_replace( '"', '""', implode( '; ', $log['categories'] ) )
			);
		}

		wp_send_json_success( [
			'csv'   => implode( "\n", $csv_lines ),
			'count' => count( $logs ),
		] );
	}

	/**
	 * Rescan scripts from the Designer API.
	 *
	 * Forces a fresh fetch of the app blocking config from the remote
	 * Designer API, then returns the updated blocking data in the same
	 * shape as get_config().
	 *
	 * @return void
	 */
	public function rescan_scripts() {
		$this->verify_request();

		$cn = Cookie_Notice();

		// Force a fresh sync from the Designer API.
		$cn->welcome_api->get_app_config( '', true );

		// Re-read the now-updated local cache and return it.
		$network  = $cn->is_network_options();
		$blocking = $network
			? get_site_option( 'cookie_notice_app_blocking', [] )
			: get_option( 'cookie_notice_app_blocking', [] );

		// CN_DEV_MODE: inject sample trackers when the real scan returns empty,
		// so the UI can be tested without real third-party scripts on the page.
		if ( defined( 'CN_DEV_MODE' ) && CN_DEV_MODE && empty( $blocking['providers'] ) ) {
			$sample_providers = [
				(object) [ 'ProviderID' => 'google-analytics', 'ProviderName' => 'Google Analytics', 'ProviderURL' => 'analytics.google.com', 'CategoryID' => 0 ],
				(object) [ 'ProviderID' => 'hotjar',           'ProviderName' => 'Hotjar',           'ProviderURL' => 'hotjar.com',            'CategoryID' => 0 ],
				(object) [ 'ProviderID' => 'meta-pixel',       'ProviderName' => 'Meta Pixel',       'ProviderURL' => 'facebook.com',          'CategoryID' => 0 ],
				(object) [ 'ProviderID' => 'hubspot',          'ProviderName' => 'HubSpot',          'ProviderURL' => 'hubspot.com',           'CategoryID' => 1 ],
				(object) [ 'ProviderID' => 'linkedin-insight',  'ProviderName' => 'LinkedIn Insight', 'ProviderURL' => 'linkedin.com',         'CategoryID' => 0 ],
			];

			if ( ! is_array( $blocking ) ) {
				$blocking = [];
			}

			$blocking['providers'] = $sample_providers;
		}

		wp_send_json_success( $this->build_blocking_response( $blocking ) );
	}

	/**
	 * Save welcome modal dismissal timestamp.
	 *
	 * Called when the user closes the modal or clicks "Don't protect my business".
	 * Stores the timestamp so the modal won't re-appear for 30 days.
	 *
	 * @return void
	 */
	public function dismiss_welcome() {
		$this->verify_request();

		$cn = Cookie_Notice();

		if ( $cn->is_network_admin() )
			update_site_option( 'cookie_notice_welcome_dismissed', current_time( 'mysql' ) );
		else
			update_option( 'cookie_notice_welcome_dismissed', current_time( 'mysql' ) );

		wp_send_json_success();
	}

	/**
	 * Mark the setup wizard as complete.
	 *
	 * Called when the user finishes (or skips) the FirstRunSetup wizard on the
	 * Settings tab. Persists a flag so the wizard doesn't re-appear.
	 *
	 * @return void
	 */
	public function complete_setup_wizard() {
		$this->verify_request();

		$cn = Cookie_Notice();

		if ( $cn->is_network_admin() )
			update_site_option( 'cookie_notice_setup_wizard_complete', true );
		else
			update_option( 'cookie_notice_setup_wizard_complete', true );

		wp_send_json_success();
	}

	/**
	 * DEV ONLY: Reset all plugin onboarding state to simulate a fresh activation.
	 * Only registered as an AJAX action when CN_DEV_MODE is true.
	 */
	public function dev_reset() {
		if ( ! defined( 'CN_DEV_MODE' ) || ! CN_DEV_MODE ) {
			wp_send_json_error( [ 'error' => 'Not available outside CN_DEV_MODE.' ] );
		}

		$this->verify_request();

		$cn = Cookie_Notice();

		// --- Step 1: Delete the API-side app record BEFORE clearing WP options. (#1956)
		//
		// After a successful use_license or register+configure flow, the Account API creates
		// an Application row for this domain. Deleting WP options alone does NOT remove it:
		// - The app record consumes a subscription slot (distorts availablelicense counts)
		// - Orphan apps accumulate across test runs
		//
		// We capture the current app_id from WP options, authenticate as the test account
		// (whose credentials are defined via CN_DEV_TEST_EMAIL + CN_DEV_TEST_PASSWORD
		// constants, falling back to env vars), then call POST /api/account/app/delete.
		//
		// This is best-effort: login or delete failures are logged but do NOT block the
		// WP options reset — the reset must always succeed regardless of API availability.
		$current_app_id = ! empty( $cn->options['general']['app_id'] ) ? $cn->options['general']['app_id'] : '';

		if ( ! empty( $current_app_id ) ) {
			$test_email    = defined( 'CN_DEV_TEST_EMAIL' )    ? CN_DEV_TEST_EMAIL    : getenv( 'CN_DEV_TEST_EMAIL' );
			$test_password = defined( 'CN_DEV_TEST_PASSWORD' ) ? CN_DEV_TEST_PASSWORD : getenv( 'CN_DEV_TEST_PASSWORD' );

			if ( ! empty( $test_email ) && ! empty( $test_password ) ) {
				// Login to get a Bearer token, then delete the app.
				$welcome_api = Cookie_Notice()->welcome;
				$login_result = $welcome_api->request( 'login', [
					'AdminID'  => $test_email,
					'Password' => $test_password,
				] );

				if ( ! empty( $login_result->data->token ) ) {
					// Store the full data object (not just the token string) — request() reads
					// $data_token->token so the shape must match what login normally stores.
					set_transient( 'cookie_notice_app_token', $login_result->data, HOUR_IN_SECONDS );

					$delete_result = $welcome_api->request( 'app_delete', [
						'AppID' => $current_app_id,
					] );

					if ( $cn->options['general']['debug_mode'] ) {
						error_log( '[Cookie Notice] dev_reset - app_delete result for ' . $current_app_id . ': ' . wp_json_encode( $delete_result ) );
					}
				} else {
					if ( $cn->options['general']['debug_mode'] ) {
						error_log( '[Cookie Notice] dev_reset - login failed for ' . $test_email . ', skipping app_delete.' );
					}
				}
			}
		}

		// --- Step 2: Clear WP options (always runs regardless of API result above).
		delete_option( 'cookie_notice_welcome_dismissed' );
		delete_option( 'cookie_notice_setup_wizard_complete' );

		$options = $cn->options['general'];
		$options['app_id']  = '';
		$options['app_key'] = '';

		if ( is_multisite() ) {
			update_site_option( 'cookie_notice_options', $options );
		} else {
			update_option( 'cookie_notice_options', $options );
		}

		$default_data = $cn->defaults['data'];

		if ( is_multisite() ) {
			update_site_option( 'cookie_notice_status', $default_data );
		} else {
			update_option( 'cookie_notice_status', $default_data );
		}

		// Clear transient caches
		delete_transient( 'cookie_notice_app_quick_config' );
		delete_site_transient( 'cookie_notice_app_quick_config' );
		delete_transient( 'cookie_notice_app_token' );
		delete_site_transient( 'cookie_notice_app_token' );

		$deleted_app = ! empty( $current_app_id ) ? $current_app_id : null;
		wp_send_json_success( [
			'message'     => 'Plugin reset to fresh-activation state.',
			'deleted_app' => $deleted_app,
		] );
	}

	/**
	 * DEV ONLY: Set a single allowlisted WP option by name.
	 * Used by Playwright tests to set fixture state without Docker/WP-CLI.
	 * Only registered as an AJAX action when CN_DEV_MODE is true.
	 *
	 * POST fields:
	 *   option_name  — one of the allowlisted option names below
	 *   option_value — string value to store
	 */
	public function test_set_option() {
		if ( ! defined( 'CN_DEV_MODE' ) || ! CN_DEV_MODE ) {
			wp_send_json_error( [ 'error' => 'Not available outside CN_DEV_MODE.' ] );
		}

		$this->verify_request();

		// Allowlist — only options the test suite legitimately needs to set.
		$allowed = [
			'cookie_notice_ui_mode',
			'cookie_notice_status',
			'cookie_notice_setup_wizard_complete',
			'cookie_notice_welcome_dismissed',
			'cookie_notice_options',
		];

		$option_name = isset( $_POST['option_name'] ) ? sanitize_key( $_POST['option_name'] ) : '';

		if ( ! in_array( $option_name, $allowed, true ) ) {
			wp_send_json_error( [ 'error' => 'Option not in allowlist: ' . $option_name ] );
		}

		// cookie_notice_options is stored as a PHP array — decode JSON input.
		$raw_value = isset( $_POST['option_value'] ) ? wp_unslash( $_POST['option_value'] ) : '';

		if ( $option_name === 'cookie_notice_options' ) {
			$option_value = json_decode( $raw_value, true );
			if ( ! is_array( $option_value ) ) {
				wp_send_json_error( [ 'error' => 'cookie_notice_options must be valid JSON object.' ] );
			}
		} else {
			$option_value = sanitize_text_field( $raw_value );
		}

		update_option( $option_name, $option_value );

		wp_send_json_success( [ 'option' => $option_name, 'value' => $option_value ] );
	}

	/**
	 * DEV ONLY: Read a single allowlisted WP option by name.
	 * Used by Playwright tests to inspect persisted state without Docker/WP-CLI.
	 * Only registered as an AJAX action when CN_DEV_MODE is true.
	 *
	 * POST fields:
	 *   option_name — one of the allowlisted option names below
	 */
	public function test_get_option() {
		if ( ! defined( 'CN_DEV_MODE' ) || ! CN_DEV_MODE ) {
			wp_send_json_error( [ 'error' => 'Not available outside CN_DEV_MODE.' ] );
		}

		$this->verify_request();

		// Allowlist — only options the test suite legitimately needs to read.
		$allowed = [
			'cookie_notice_options',
			'cookie_notice_status',
			'cookie_notice_ui_mode',
			'cookie_notice_setup_wizard_complete',
			'cookie_notice_welcome_dismissed',
			'cookie_notice_app_blocking',
			'cookie_notice_app_design',
		];

		$option_name = isset( $_POST['option_name'] ) ? sanitize_key( $_POST['option_name'] ) : '';

		if ( ! in_array( $option_name, $allowed, true ) ) {
			wp_send_json_error( [ 'error' => 'Option not in allowlist: ' . $option_name ] );
		}

		$value = get_option( $option_name );

		// Serialize arrays/objects so the test can inspect them as a string.
		if ( is_array( $value ) || is_object( $value ) ) {
			$value = wp_json_encode( $value );
		}

		wp_send_json_success( [ 'option' => $option_name, 'value' => (string) $value ] );
	}

	/**
	 * Decode a WAF-safe save field: strict-decode-or-reject with legacy passthrough.
	 *
	 * The React admin base64-encodes code/markup-bearing fields behind
	 * self::WAF_B64_SENTINEL so a WAF can't 403 the POST. This reverses that:
	 *
	 *  - No sentinel  → legacy (non-encoded) payload. Sets $ok = true and returns
	 *    the raw value UNCHANGED; the caller then runs today's exact passthrough
	 *    (wp_unslash etc.). The sentinel is checked on the RAW $_POST value (post
	 *    wp_magic_quotes, pre wp_unslash): this works because the marker is
	 *    printable ASCII with none of the characters addslashes escapes (no NUL,
	 *    quote, or backslash), so wp_magic_quotes leaves the prefix byte-identical
	 *    and the strncmp match holds. The base64 body is likewise slash-free.
	 *  - Sentinel present → strip it, strict base64_decode (4th arg true), then
	 *    require valid UTF-8. On any failure sets $ok = false and returns '' so the
	 *    caller can reject the whole save (no partial store). On success sets
	 *    $ok = true and returns the decoded bytes VERBATIM — the caller MUST store
	 *    them directly, with NO second wp_unslash (a second unslash corrupts
	 *    backslash-bearing scripts). An empty field (sentinel + '') round-trips to ''.
	 *
	 * Kept static + pure (no $this) so it is unit-testable in isolation.
	 *
	 * @param  string $raw The raw $_POST value (not yet wp_unslash'd).
	 * @param  bool   $ok  Out-param: true on success/passthrough, false on decode failure.
	 * @return string      Decoded value, unchanged passthrough value, or '' on failure.
	 */
	private static function decode_waf_field( $raw, &$ok ) {
		$sentinel = self::WAF_B64_SENTINEL;
		$len      = strlen( $sentinel );

		// Not sentinel-tagged → legacy passthrough, unchanged.
		if ( strncmp( (string) $raw, $sentinel, $len ) !== 0 ) {
			$ok = true;
			return $raw;
		}

		$body    = substr( (string) $raw, $len );
		$decoded = base64_decode( $body, true ); // strict: false on any non-base64 input

		if ( $decoded === false || ! mb_check_encoding( $decoded, 'UTF-8' ) ) {
			$ok = false;
			return '';
		}

		$ok = true;
		return $decoded;
	}

	/**
	 * Save plugin options submitted from the React admin UI.
	 *
	 * Reads each recognized POST field, sanitizes it, and merges it into the
	 * existing options array before persisting via update_option() (single-site)
	 * or update_site_option() (network).
	 *
	 * @return void
	 */
	public function save_options() {
		$this->verify_request();

		$cn      = Cookie_Notice();
		$options = $cn->options['general'];

		// Capture the connected app id before any $_POST override, so a connection
		// change can be detected after persist (see the refresh block at the end).
		$old_app_id = isset( $options['app_id'] ) ? $options['app_id'] : '';

		// WAF-safe decode gate (#47585, #47616). The React admin base64-encodes the
		// code/markup-bearing fields (refuse_code, refuse_code_head, conditional_rules)
		// behind self::WAF_B64_SENTINEL so a firewall can't 403 the POST for carrying a
		// raw <script>. Decode-or-reject ALL of them up-front, before any $options
		// mutation for these fields, so a corrupt encoded payload rejects the whole save
		// (no partial store). Legacy (non-sentinel) values pass through unchanged and the
		// existing per-field passthrough below still applies.
		//
		// array_key_exists( '<field>', $waf )  → field was sentinel-decoded; store the
		//     decoded value DIRECTLY (NO second wp_unslash — that corrupts backslash-bearing
		//     scripts). Key-presence (not isset) is the test, so an empty decoded value counts.
		// key absent  → field is a legacy passthrough; keep today's exact wp_unslash below.
		$waf              = [];
		$waf_fields       = [ 'refuse_code', 'refuse_code_head', 'conditional_rules' ];
		$sentinel         = self::WAF_B64_SENTINEL;
		$sentinel_len     = strlen( $sentinel );

		foreach ( $waf_fields as $waf_field ) {
			if ( ! isset( $_POST[ $waf_field ] ) ) {
				continue;
			}

			// Was this value sentinel-tagged (encoded) or a legacy passthrough? Match
			// the marker on the RAW value (post wp_magic_quotes, pre wp_unslash). The
			// marker is printable ASCII with no addslashes-escaped chars (no NUL, quote,
			// or backslash), so wp_magic_quotes leaves it intact and this strncmp holds.
			$is_encoded = strncmp( (string) $_POST[ $waf_field ], $sentinel, $sentinel_len ) === 0;

			$ok      = false;
			$decoded = self::decode_waf_field( $_POST[ $waf_field ], $ok );

			if ( ! $ok ) {
				// Reject the entire save — no store, no partial write.
				wp_send_json_error( [ 'error' => __( 'Could not save: the settings payload could not be decoded. Please retry.', 'cookie-notice' ) ] );
			}

			// Record the decoded value only for the sentinel branch; a legacy
			// passthrough is left absent so it keeps today's exact wp_unslash below.
			if ( $is_encoded ) {
				$waf[ $waf_field ] = $decoded;
			}
		}

		// Boolean fields.
		$bool_fields = [
			'refuse_opt',
			'revoke_cookies',
			'on_scroll',
			'on_click',
			'redirection',
			'see_more',
			'bot_detection',
			'amp_support',
			'caching_compatibility',
			'debug_mode',
			'wp_consent_api',
			'conditional_active',
			'deactivation_delete',
			'app_blocking',
		];

		foreach ( $bool_fields as $field ) {
			if ( isset( $_POST[ $field ] ) ) {
				$options[ $field ] = (bool) $_POST[ $field ];
			}
		}

		// Server-side threshold enforcement: cap app_blocking to false when
		// the free-plan visit limit is exceeded, matching settings.php:1965.
		if ( ! empty( $options['app_blocking'] ) && $cn->threshold_exceeded() ) {
			$options['app_blocking'] = false;
		}

		// Text fields.
		$text_fields = [
			'message_text',
			'accept_text',
			'refuse_text',
			'revoke_text',
			'revoke_message_text',
			'css_class',
		];

		foreach ( $text_fields as $field ) {
			if ( isset( $_POST[ $field ] ) ) {
				$options[ $field ] = sanitize_text_field( $_POST[ $field ] );
			}
		}

		// Connection credential fields — sanitize_key strips to lowercase alphanumeric + dashes/underscores.
		if ( isset( $_POST['app_id'] ) ) {
			$options['app_id'] = sanitize_key( $_POST['app_id'] );
		}

		if ( isset( $_POST['app_key'] ) ) {
			$options['app_key'] = sanitize_key( $_POST['app_key'] );
		}

		// Script blocking code fields — these can contain <script> tags.
		// Sentinel-decoded (WAF-safe) values are stored VERBATIM: base64 decode
		// already yields the exact bytes the admin typed, so a second wp_unslash
		// would corrupt any backslash-bearing script/regex. Legacy (non-encoded)
		// values keep today's exact wp_unslash passthrough (admin-only, manage_options).
		if ( array_key_exists( 'refuse_code', $waf ) ) {
			$options['refuse_code'] = $waf['refuse_code'];
		} elseif ( isset( $_POST['refuse_code'] ) ) {
			$options['refuse_code'] = wp_unslash( $_POST['refuse_code'] );
		}

		if ( array_key_exists( 'refuse_code_head', $waf ) ) {
			$options['refuse_code_head'] = $waf['refuse_code_head'];
		} elseif ( isset( $_POST['refuse_code_head'] ) ) {
			$options['refuse_code_head'] = wp_unslash( $_POST['refuse_code_head'] );
		}

		// Excluded script handles — newline-separated string from React textarea → stored as array.
		if ( isset( $_POST['excluded_handles'] ) ) {
			$options['excluded_handles'] = array_values( array_filter( array_map( 'sanitize_text_field', explode( "\n", $_POST['excluded_handles'] ) ) ) );
		}

		// Conditional rules — JSON string from React → validated nested array.
		// Sentinel-decoded values are already the exact JSON bytes (no wp_unslash);
		// legacy values keep today's wp_unslash. The per-rule validation loop below
		// is unchanged for both paths.
		if ( isset( $_POST['conditional_rules'] ) ) {
			$raw_rules = array_key_exists( 'conditional_rules', $waf )
				? json_decode( $waf['conditional_rules'], true )
				: json_decode( wp_unslash( $_POST['conditional_rules'] ), true );

			if ( is_array( $raw_rules ) ) {
				$settings  = Cookie_Notice()->settings;
				$group_id  = 1;
				$rules     = [];

				foreach ( $raw_rules as $group ) {
					if ( ! is_array( $group ) || empty( $group ) ) {
						continue;
					}

					$rule_id = 1;

					foreach ( $group as $rule ) {
						if ( ! is_array( $rule ) ) {
							continue;
						}

						$param    = sanitize_key( $rule['param'] ?? '' );
						$operator = sanitize_key( $rule['operator'] ?? '' );
						$value    = $param === 'taxonomy_archive'
							? ( $rule['value'] ?? '' )
							: sanitize_key( $rule['value'] ?? '' );

						if ( $param && $operator && $value !== '' && $settings->check_rule( $param, $operator, $value ) ) {
							$rules[ $group_id ][ $rule_id++ ] = [
								'param'    => $param,
								'operator' => $operator,
								'value'    => $value,
							];
						}
					}

					if ( ! empty( $rules[ $group_id ] ) ) {
						$group_id++;
					}
				}

				$options['conditional_rules'] = $rules;
			} else {
				$options['conditional_rules'] = [];
			}
		}

		// Select fields — value must be one of the allowed options.
		$select_fields = [
			'revoke_cookies_opt' => [ 'automatic', 'manual' ],
			'time'               => [ 'hour', 'day', 'week', 'month', '3months', '6months', 'year', 'infinity' ],
			'time_rejected'      => [ 'hour', 'day', 'week', 'month', '3months', '6months', 'year', 'infinity' ],
			'link_target'        => [ '_blank', '_self' ],
			'link_position'      => [ 'banner', 'message' ],
			'position'           => [ 'top', 'bottom', 'left', 'right', 'popup' ],
			'displayType'        => [ 'fixed', 'floating' ],
			'hide_effect'        => [ 'none', 'fade', 'slide' ],
			'script_placement'   => [ 'header', 'footer' ],
			'conditional_display' => [ 'hide', 'show' ],
			'ui_mode'             => [ 'react', 'legacy' ],
		];

		foreach ( $select_fields as $field => $allowed ) {
			if ( isset( $_POST[ $field ] ) ) {
				$value = sanitize_text_field( $_POST[ $field ] );
				if ( in_array( $value, $allowed, true ) ) {
					$options[ $field ] = $value;
				}
			}
		}

		// Number fields.
		if ( isset( $_POST['on_scroll_offset'] ) ) {
			$options['on_scroll_offset'] = absint( $_POST['on_scroll_offset'] );
		}

		// Nested colors array — text, button, bar, bar_opacity.
		$color_fields = [ 'text', 'button', 'bar' ];
		foreach ( $color_fields as $color_field ) {
			$post_key = 'color_' . $color_field;
			if ( isset( $_POST[ $post_key ] ) ) {
				$val = sanitize_hex_color( $_POST[ $post_key ] );
				if ( $val ) {
					$options['colors'][ $color_field ] = $val;
				}
			}
		}

		// bar_opacity lives inside the nested colors array; clamp to 50–100.
		if ( isset( $_POST['bar_opacity'] ) ) {
			$bar_opacity = absint( $_POST['bar_opacity'] );
			$bar_opacity = max( 50, min( 100, $bar_opacity ) );
			$options['colors']['bar_opacity'] = $bar_opacity;
		}

		// Nested see_more_opt array.
		if ( isset( $_POST['see_more_opt'] ) && is_array( $_POST['see_more_opt'] ) ) {
			$raw = $_POST['see_more_opt'];

			if ( isset( $raw['text'] ) ) {
				$options['see_more_opt']['text'] = sanitize_text_field( $raw['text'] );
			}

			if ( isset( $raw['link_type'] ) ) {
				$link_type = sanitize_text_field( $raw['link_type'] );
				if ( in_array( $link_type, [ 'page', 'custom' ], true ) ) {
					$options['see_more_opt']['link_type'] = $link_type;
				}
			}

			if ( isset( $raw['id'] ) ) {
				$options['see_more_opt']['id'] = absint( $raw['id'] );
			}

			if ( isset( $raw['link'] ) ) {
				$options['see_more_opt']['link'] = esc_url_raw( $raw['link'] );
			}

			if ( isset( $raw['sync'] ) ) {
				$options['see_more_opt']['sync'] = (bool) $raw['sync'];
			}
		}

		// Enforce field ownership partition (#2264) — strip any key that is
		// not declared in Cookie_Notice::$plugin_owned_fields. Nested sub-arrays
		// (colors, see_more_opt, conditional_rules) are already in the allowlist.
		$allowed = Cookie_Notice::$plugin_owned_fields;

		foreach ( array_keys( $options ) as $key ) {
			if ( ! in_array( $key, $allowed, true ) ) {
				unset( $options[ $key ] );
			}
		}

		// Persist — network vs. single-site.
		if ( isset( $_POST['cn_network'] ) && $_POST['cn_network'] ) {
			update_site_option( 'cookie_notice_options', $options );
		} else {
			update_option( 'cookie_notice_options', $options );
		}

		// Connection changed — refresh cached app data for the new app id.
		//
		// The cached analytics ( cookie_notice_app_analytics ) and derived status
		// ( cookie_notice_status['threshold_exceeded'] ) are otherwise refreshed only
		// hourly via the cookie_notice_get_app_analytics cron. Without this, a domain
		// reconnected from a Free to a Pro app keeps showing the Free-plan visit-limit
		// notice -- and the app_blocking cap applied above -- until the cron next runs.
		// Mirrors the legacy form path in Settings::validate_options().
		$new_app_id = isset( $options['app_id'] ) ? $options['app_id'] : '';

		if ( $new_app_id !== '' && ! empty( $options['app_key'] ) && $new_app_id !== $old_app_id ) {
			// Mirror the just-persisted credentials into the in-memory options so the
			// config/token requests below authenticate as the new app, exactly as the
			// legacy form path does after register_setting() writes the new options.
			$cn->options['general'] = $options;

			$app_data = $cn->welcome_api->get_app_config( $new_app_id, true, false );

			// get_app_config() returns the status_data array normally, but can
			// return null (its one-cron-per-hour throttle branch). Guard the shape
			// before reading 'status' so a null/partial return can't fatal the save.
			if ( is_array( $app_data ) && isset( $app_data['status'] ) && $cn->check_status( $app_data['status'] ) === 'active' ) {
				// get_app_analytics authenticates with the just-saved credentials via the
				// analytics_app_data shim ( welcome-api.php 'get_analytics' request branch ).
				$cn->settings->set_analytics_app_data( [ 'id' => $new_app_id, 'key' => $options['app_key'] ] );
				$cn->welcome_api->get_app_analytics( $new_app_id, true, false );
				$cn->settings->set_analytics_app_data( [] );
			}
		}

		wp_send_json_success( [ 'message' => __( 'Settings saved.', 'cookie-notice' ) ] );
	}

	/**
	 * Return the active API environment URLs.
	 *
	 * Used by integration tests to verify that the WP instance is targeting
	 * stage APIs before any live API calls are made. Always registered —
	 * does not require CN_DEV_MODE.
	 *
	 * @return void
	 */
	/**
	 * Return conditional display rule values for a given parameter type.
	 *
	 * Called when the user changes the param dropdown in the rule builder.
	 * Returns a flat array of { value, label } objects (and optionally grouped).
	 *
	 * @return void
	 */
	public function get_rule_values() {
		$this->verify_request();

		$param = isset( $_POST['param'] ) ? sanitize_key( $_POST['param'] ) : '';

		if ( ! $param ) {
			wp_send_json_error( [ 'message' => 'Missing param' ] );
		}

		$values = [];

		switch ( $param ) {
			case 'page_type':
				$values = [
					[ 'value' => 'front', 'label' => __( 'Front Page', 'cookie-notice' ) ],
					[ 'value' => 'home', 'label' => __( 'Home Page', 'cookie-notice' ) ],
				];
				break;

			case 'page':
				$pages = get_pages( [ 'post_status' => [ 'publish', 'private', 'future' ] ] );
				$front = (int) get_option( 'page_on_front' );
				$blog  = (int) get_option( 'page_for_posts' );

				foreach ( $pages as $page ) {
					if ( $page->ID === $front || $page->ID === $blog ) {
						continue;
					}
					$values[] = [ 'value' => (string) $page->ID, 'label' => $page->post_title ];
				}
				break;

			case 'post_type':
				$types = get_post_types( [ 'public' => true ], 'objects' );

				foreach ( $types as $type ) {
					$values[] = [ 'value' => $type->name, 'label' => $type->labels->singular_name ];
				}
				break;

			case 'post_type_archive':
				$types = get_post_types( [ 'public' => true, 'has_archive' => true ], 'objects' );

				foreach ( $types as $type ) {
					$values[] = [ 'value' => $type->name, 'label' => $type->labels->singular_name ];
				}
				break;

			case 'user_type':
				$values = [
					[ 'value' => 'logged_in', 'label' => __( 'Logged in', 'cookie-notice' ) ],
					[ 'value' => 'guest', 'label' => __( 'Guest', 'cookie-notice' ) ],
				];
				break;

			case 'taxonomy_archive':
				$taxonomies = get_taxonomies( [ 'public' => true ], 'objects' );

				foreach ( $taxonomies as $taxonomy ) {
					$terms = get_terms( [ 'taxonomy' => $taxonomy->name, 'hide_empty' => false ] );

					if ( is_wp_error( $terms ) || empty( $terms ) ) {
						continue;
					}

					$group = [
						'group' => $taxonomy->labels->name,
						'items' => [],
					];

					foreach ( $terms as $term ) {
						$group['items'][] = [
							'value' => $term->term_id . '|' . $taxonomy->name,
							'label' => $term->name,
						];
					}

					$values[] = $group;
				}
				break;
		}

		wp_send_json_success( [ 'values' => $values ] );
	}

	public function get_api_environment() {
		$this->verify_request();

		$cn = Cookie_Notice();

		wp_send_json_success( [
			'host'              => $cn->get_url( 'host' ),
			'account_api'       => $cn->get_url( 'account_api' ),
			'designer_api'      => $cn->get_url( 'designer_api' ),
			'transactional_api' => $cn->get_url( 'transactional_api' ),
			'widget'            => $cn->get_url( 'widget' ),
		] );
	}

	/**
	 * Build the standardised blocking + config response shape.
	 *
	 * Shared by get_config() and rescan_scripts() to avoid maintaining the
	 * 7-key blocking object in two places.
	 *
	 * @param array $blocking Raw blocking option (cookie_notice_app_blocking).
	 * @return array { 'blocking' => [...], 'config' => object }
	 */
	private function build_blocking_response( $blocking ) {
		if ( empty( $blocking ) ) {
			return [
				'blocking' => [
					'providers'                  => [],
					'patterns'                   => [],
					'google_consent_default'     => null,
					'facebook_consent_default'   => null,
					'microsoft_consent_default'  => null,
					'gpc_support'                => false,
					'do_not_track'               => false,
				],
				'config' => new stdClass(),
			];
		}

		$config = isset( $blocking['banner_config'] ) && is_array( $blocking['banner_config'] )
			? $blocking['banner_config']
			: new stdClass();

		return [
			'blocking' => [
				'providers'                  => isset( $blocking['providers'] ) ? $blocking['providers'] : [],
				'patterns'                   => isset( $blocking['patterns'] ) ? $blocking['patterns'] : [],
				'google_consent_default'     => isset( $blocking['google_consent_default'] ) ? $blocking['google_consent_default'] : null,
				'facebook_consent_default'   => isset( $blocking['facebook_consent_default'] ) ? $blocking['facebook_consent_default'] : null,
				'microsoft_consent_default'  => isset( $blocking['microsoft_consent_default'] ) ? $blocking['microsoft_consent_default'] : null,
				'gpc_support'                => ! empty( $blocking['gpc_support'] ),
				'do_not_track'               => ! empty( $blocking['do_not_track'] ),
				'lastUpdated'                => isset( $blocking['lastUpdated'] ) ? $blocking['lastUpdated'] : '',
			],
			'config' => $config,
		];
	}

	/**
	 * Compute consent breakdown (accept/custom/reject rates) from level totals.
	 *
	 * Shared by get_dashboard() and transform_consent_logs().
	 *
	 * @param array $level_totals Associative [ 1 => reject_count, 2 => custom_count, 3 => accept_count ].
	 * @return array { 'total' => int, 'acceptRate' => int, 'customRate' => int, 'rejectRate' => int, 'levelLabels' => array }
	 */
	private function compute_consent_breakdown( $level_totals ) {
		$total = array_sum( $level_totals );

		return [
			'total'      => $total,
			'acceptRate' => $total > 0 ? round( $level_totals[3] / $total * 100 ) : 0,
			'customRate' => $total > 0 ? round( $level_totals[2] / $total * 100 ) : 0,
			'rejectRate' => $total > 0 ? round( $level_totals[1] / $total * 100 ) : 0,
			'levelLabels' => $this->get_level_labels(),
		];
	}

	/**
	 * Read customer-configured consent level labels from cached Designer API data.
	 *
	 * Labels are cached in cookie_notice_app_design by get_app_config() from
	 * DefaultUserTextJSON. Falls back to platform defaults if not yet cached.
	 *
	 * @return array { 'level1' => string, 'level2' => string, 'level3' => string }
	 */
	private function get_level_labels() {
		$cn      = Cookie_Notice();
		$network = $cn->is_network_options();
		$design  = $network
			? get_site_option( 'cookie_notice_app_design', [] )
			: get_option( 'cookie_notice_app_design', [] );

		return [
			'level1' => ! empty( $design['levelNameText_1'] ) ? $design['levelNameText_1'] : 'Private',
			'level2' => ! empty( $design['levelNameText_2'] ) ? $design['levelNameText_2'] : 'Balanced',
			'level3' => ! empty( $design['levelNameText_3'] ) ? $design['levelNameText_3'] : 'Personalized',
		];
	}
}
