<?php
namespace PowerpackElements\Classes;

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

/**
 * REST endpoints backing the admin settings screen.
 *
 * Every read and write goes through PP_Settings_Registry, so the stored option
 * keys and value shapes are defined in exactly one place.
 *
 * Two deliberate departures from the legacy form handlers:
 *
 * - Writes are partial. Only keys present in the payload are touched, so one
 *   panel can never blank another's options.
 * - Nothing in the save path makes an outbound HTTP request. Credential
 *   verification and license checks are separate, explicit endpoints, so a
 *   Facebook or Google outage can never block an unrelated setting from saving.
 *
 * @since 3.0.0
 */
final class PP_Settings_REST_Controller {

	/**
	 * REST namespace. Not named NAMESPACE, which is a reserved word.
	 */
	const REST_NAMESPACE = 'powerpack/v1';

	/** User meta recording that the setup checklist has been dismissed. */
	const SETUP_DISMISSED_META = 'pp_welcome_setup_dismissed';

	/**
	 * How long a credential verification verdict is cached.
	 */
	const VERIFY_CACHE_TTL = DAY_IN_SECONDS;

	/**
	 * Where the used / not used classification is cached, and for how long.
	 *
	 * Working it out means reading every Elementor document on the site, so it
	 * is never computed as part of a normal page load.
	 */
	const USAGE_CACHE_KEY = 'pp_modules_usage';
	const USAGE_CACHE_TTL = HOUR_IN_SECONDS;

	/**
	 * How long a widget flagged 'is_new' in PP_Config keeps its badge.
	 *
	 * Counted from when this site first saw the widget rather than from the
	 * release date, so a site that updates months late still gets the badge —
	 * that site is exactly who it is for. Roughly two release cycles: long
	 * enough for someone who visits this screen occasionally to catch it,
	 * short enough to be gone by the next feature release.
	 *
	 * The timer only covers ordinary sites. Clearing the 'is_new' flags of the
	 * previous release is part of preparing the next one, and that is what
	 * stops a badge appearing on a site updating from a year-old build.
	 *
	 * @since 3.0.0
	 * @var int
	 */
	const NEW_BADGE_PERIOD = 60 * DAY_IN_SECONDS;

	/**
	 * Option recording when this site first saw each widget flagged as new.
	 *
	 * @since 3.0.0
	 * @var string
	 */
	const NEW_SEEN_OPTION = 'pp_new_widgets_seen';

	/**
	 * Register the hook.
	 *
	 * @since 3.0.0
	 * @return void
	 */
	public static function init() {
		add_action( 'rest_api_init', [ __CLASS__, 'register_routes' ] );
	}

	/**
	 * Register all routes.
	 *
	 * @since 3.0.0
	 * @return void
	 */
	public static function register_routes() {
		$network_arg = [
			'network' => [
				'type'              => 'boolean',
				'default'           => false,
				'description'       => 'Operate on network wide options rather than the current site.',
				'sanitize_callback' => 'rest_sanitize_boolean',
			],
		];

		register_rest_route( self::REST_NAMESPACE, '/settings', [
			[
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => [ __CLASS__, 'get_settings' ],
				'permission_callback' => [ __CLASS__, 'can_read' ],
				'args'                => $network_arg,
			],
			[
				'methods'             => \WP_REST_Server::EDITABLE,
				'callback'            => [ __CLASS__, 'update_settings' ],
				'permission_callback' => [ __CLASS__, 'can_read' ],
				'args'                => array_merge( $network_arg, [
					'settings' => [
						'type'        => 'object',
						'required'    => true,
						'description' => 'Map of setting key to value. Absent keys are left untouched.',
					],
				] ),
			],
		] );

		register_rest_route( self::REST_NAMESPACE, '/modules', [
			'methods'             => \WP_REST_Server::READABLE,
			'callback'            => [ __CLASS__, 'get_modules' ],
			'permission_callback' => [ __CLASS__, 'can_edit_posts' ],
		] );

		register_rest_route( self::REST_NAMESPACE, '/modules/usage', [
			'methods'             => \WP_REST_Server::READABLE,
			'callback'            => [ __CLASS__, 'get_module_usage' ],
			'permission_callback' => [ __CLASS__, 'can_edit_posts' ],
			'args'                => [
				'refresh' => [
					'type'              => 'boolean',
					'default'           => false,
					'description'       => 'Recompute instead of using the cached classification.',
					'sanitize_callback' => 'rest_sanitize_boolean',
				],
			],
		] );

		register_rest_route( self::REST_NAMESPACE, '/license', [
			[
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => [ __CLASS__, 'get_license' ],
				'permission_callback' => [ __CLASS__, 'can_manage' ],
				'args'                => [
					'refresh' => [
						'type'              => 'boolean',
						'default'           => false,
						'description'       => 'Re-check the license against the store. Makes a remote request.',
						'sanitize_callback' => 'rest_sanitize_boolean',
					],
				],
			],
			[
				'methods'             => \WP_REST_Server::DELETABLE,
				'callback'            => [ __CLASS__, 'remove_license' ],
				'permission_callback' => [ __CLASS__, 'can_manage' ],
			],
		] );

		register_rest_route( self::REST_NAMESPACE, '/license/activate', [
			'methods'             => \WP_REST_Server::CREATABLE,
			'callback'            => [ __CLASS__, 'activate_license' ],
			'permission_callback' => [ __CLASS__, 'can_manage' ],
			'args'                => [
				'key' => [
					'type'        => 'string',
					'required'    => true,
					'description' => 'License key to activate.',
				],
			],
		] );

		register_rest_route( self::REST_NAMESPACE, '/license/deactivate', [
			'methods'             => \WP_REST_Server::CREATABLE,
			'callback'            => [ __CLASS__, 'deactivate_license' ],
			'permission_callback' => [ __CLASS__, 'can_manage' ],
		] );

		register_rest_route( self::REST_NAMESPACE, '/rollback', [
			[
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => [ __CLASS__, 'get_rollback' ],
				'permission_callback' => [ __CLASS__, 'can_rollback' ],
				'args'                => [
					'refresh' => [
						'type'              => 'boolean',
						'default'           => false,
						'description'       => 'Re-fetch the package list from the store instead of using the cached one.',
						'sanitize_callback' => 'rest_sanitize_boolean',
					],
				],
			],
			[
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => [ __CLASS__, 'run_rollback' ],
				'permission_callback' => [ __CLASS__, 'can_rollback' ],
				'args'                => [
					'file_key' => [
						'type'        => 'string',
						'required'    => true,
						'description' => 'Key of the package to install, from the list this route returns.',
					],
				],
			],
		] );

		register_rest_route( self::REST_NAMESPACE, '/integration/verify', [
			'methods'             => \WP_REST_Server::CREATABLE,
			'callback'            => [ __CLASS__, 'verify_credential' ],
			'permission_callback' => [ __CLASS__, 'can_manage' ],
			'args'                => [
				'field' => [
					'type'     => 'string',
					'required' => true,
					'enum'     => [ 'pp_fb_app_id', 'pp_google_places_api_key' ],
				],
				'value' => [
					'type'        => 'string',
					'required'    => false,
					'description' => 'Value to check. Omit to verify the stored value.',
				],
			],
		] );

		register_rest_route( self::REST_NAMESPACE, '/templates', [
			'methods'             => \WP_REST_Server::READABLE,
			'callback'            => [ __CLASS__, 'get_templates' ],
			'permission_callback' => [ __CLASS__, 'can_manage' ],
			'args'                => [
				'type' => [
					'type'    => 'string',
					'default' => 'header_footer',
					'enum'    => [ 'header_footer', 'woo', 'pages' ],
				],
			],
		] );

		register_rest_route( self::REST_NAMESPACE, '/welcome/dismiss-setup', [
			'methods'             => \WP_REST_Server::CREATABLE,
			'callback'            => [ __CLASS__, 'dismiss_setup' ],
			'permission_callback' => [ __CLASS__, 'can_edit_posts' ],
		] );

		register_rest_route( self::REST_NAMESPACE, '/welcome', [
			'methods'             => \WP_REST_Server::READABLE,
			'callback'            => [ __CLASS__, 'get_welcome' ],
			'permission_callback' => [ __CLASS__, 'can_edit_posts' ],
			'args'                => [
				'posts' => [
					'type'              => 'boolean',
					'default'           => true,
					'description'       => 'Include blog posts. Fetching them makes a remote request.',
					'sanitize_callback' => 'rest_sanitize_boolean',
				],
			],
		] );
	}

	/* ---------------------------------------------------------------------
	 * Permissions
	 * ------------------------------------------------------------------ */

	/**
	 * Whether the user may see the settings screen at all.
	 *
	 * This is the coarse gate. Per field capabilities are enforced inside the
	 * registry on both read and write, so a user who passes here still only
	 * sees and writes the groups they are entitled to.
	 *
	 * @since 3.0.0
	 * @param \WP_REST_Request $request Request.
	 * @return bool|\WP_Error
	 */
	public static function can_read( $request ) {
		if ( $request->get_param( 'network' ) ) {
			return current_user_can( 'manage_network_plugins' ) ? true : self::forbidden();
		}

		if ( current_user_can( 'edit_posts' ) || current_user_can( 'manage_options' ) ) {
			return true;
		}

		return self::forbidden();
	}

	/**
	 * Capability gate for the widget library.
	 *
	 * @since 3.0.0
	 * @return bool|\WP_Error
	 */
	public static function can_edit_posts() {
		return current_user_can( 'edit_posts' ) ? true : self::forbidden();
	}

	/**
	 * Capability gate for administrator only endpoints.
	 *
	 * @since 3.0.0
	 * @return bool|\WP_Error
	 */
	public static function can_manage() {
		return current_user_can( 'manage_options' ) ? true : self::forbidden();
	}

	/**
	 * Capability gate for the rollback routes.
	 *
	 * Rolling back rewrites the plugin directory, so it is gated on the
	 * capability that governs installing plugin updates rather than on the one
	 * that governs editing settings. The conditions of the install — network
	 * activation, DISALLOW_FILE_MODS — are checked in PP_Rollback::can_run(),
	 * which reports them instead of returning a bare 403.
	 *
	 * @since 3.0.0
	 * @return bool|\WP_Error
	 */
	public static function can_rollback() {
		return current_user_can( 'update_plugins' ) ? true : self::forbidden();
	}

	/**
	 * Standard forbidden response.
	 *
	 * @since 3.0.0
	 * @return \WP_Error
	 */
	private static function forbidden() {
		return new \WP_Error(
			'pp_rest_forbidden',
			esc_html__( 'You are not allowed to manage these settings.', 'powerpack' ),
			[ 'status' => rest_authorization_required_code() ]
		);
	}

	/* ---------------------------------------------------------------------
	 * Settings
	 * ------------------------------------------------------------------ */

	/**
	 * Read every setting the current user is entitled to.
	 *
	 * @since 3.0.0
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response
	 */
	public static function get_settings( $request ) {
		$network = (bool) $request->get_param( 'network' );

		return rest_ensure_response( self::settings_payload( $network ) );
	}

	/**
	 * Apply a partial settings update.
	 *
	 * @since 3.0.0
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public static function update_settings( $request ) {
		$network = (bool) $request->get_param( 'network' );
		$payload = $request->get_param( 'settings' );

		if ( ! is_array( $payload ) ) {
			return new \WP_Error(
				'pp_rest_invalid_payload',
				esc_html__( 'Settings must be an object of key and value pairs.', 'powerpack' ),
				[ 'status' => 400 ]
			);
		}

		$unknown = array_diff( array_keys( $payload ), array_keys( PP_Settings_Registry::get_fields() ) );
		$result  = PP_Settings_Registry::apply( $payload, [ 'network' => $network ] );

		$response = self::settings_payload( $network );

		$response['written'] = $result['written'];
		$response['skipped'] = $result['skipped'];
		$response['unknown'] = array_values( $unknown );

		return rest_ensure_response( $response );
	}

	/**
	 * Build the settings payload: values, field descriptors and group list.
	 *
	 * Credentials come back masked. The stored value never leaves the server,
	 * which is why the client must omit an unedited credential from its payload
	 * rather than echoing the mask back.
	 *
	 * @since 3.0.0
	 * @param bool $network Whether to operate on network wide options.
	 * @return array
	 */
	private static function settings_payload( $network ) {
		$settings  = [];
		$fields    = [];
		$groups    = [];
		$show_docs = null;

		foreach ( PP_Settings_Registry::get_fields() as $key => $field ) {
			if ( ! PP_Settings_Registry::current_user_can( $key, $network ) ) {
				continue;
			}

			$settings[ $key ] = PP_Settings_Registry::read_for_output( $key, $network );

			$descriptor = [
				'group'  => $field['group'],
				'type'   => $field['type'],
				'secret' => ! empty( $field['secret'] ),
			];

			$choices = self::resolve_choices( $field );

			if ( null !== $choices ) {
				$descriptor['choices'] = $choices;
			}

			/*
			 * A media field stores an attachment id, which means nothing to the
			 * browser. The URL to draw comes with it, since only the server can
			 * resolve one from the other.
			 */
			if ( 'media' === $field['type'] ) {
				$stored = self::plain_text( PP_Settings_Registry::read( $key, $network ) );

				if ( '' !== $stored ) {
					$descriptor['preview'] = is_numeric( $stored )
						? (string) wp_get_attachment_url( absint( $stored ) )
						: $stored;
				}
			}

			/*
			 * Documentation links for a list's choices. Read once per request
			 * rather than per field, and dropped entirely when white label
			 * hides docs links.
			 */
			if ( ! empty( $field['docs'] ) && is_callable( $field['docs'] ) ) {
				if ( null === $show_docs ) {
					$wl        = PP_Admin_Settings::get_settings();
					$show_docs = 'on' !== $wl['hide_docs_links'];
				}

				if ( $show_docs ) {
					$descriptor['docs'] = (array) call_user_func( $field['docs'] );
				}
			}

			$fields[ $key ]           = $descriptor;
			$groups[ $field['group'] ] = true;
		}

		return [
			'settings' => (object) $settings,
			'fields'   => (object) $fields,
			'groups'   => array_keys( $groups ),
			'network'  => (bool) $network,
		];
	}

	/**
	 * Resolve a field's allowed values into a label map the client can render.
	 *
	 * @since 3.0.0
	 * @param array $field Field descriptor.
	 * @return array|null
	 */
	private static function resolve_choices( $field ) {
		if ( empty( $field['choices'] ) ) {
			return null;
		}

		if ( is_callable( $field['choices'] ) ) {
			return (array) call_user_func( $field['choices'] );
		}

		$choices = [];

		foreach ( (array) $field['choices'] as $choice ) {
			$choices[ $choice ] = $choice;
		}

		return $choices;
	}

	/* ---------------------------------------------------------------------
	 * Widget library
	 * ------------------------------------------------------------------ */

	/**
	 * The widget catalogue, grouped by category, with enabled state.
	 *
	 * Writes do not come back here: the client submits the complete enabled
	 * list as 'pp_elementor_modules' through the settings endpoint. The legacy
	 * screen instead posted only the widgets visible under the active filter
	 * and reconstructed the rest with set arithmetic against options written as
	 * a side effect of the previous page render, which is what made the filter
	 * views hazardous.
	 *
	 * The used / not used filter is not carried over for now. Adding it later
	 * is a display concern only: classification would ride along on each widget
	 * here, and the submitted list stays complete either way.
	 *
	 * @since 3.0.0
	 * @return \WP_REST_Response
	 */
	public static function get_modules() {
		$widget_info = PP_Config::get_widget_info();
		$all_modules = pp_get_modules();
		$enabled     = pp_get_enabled_modules_lookup();
		$wl          = PP_Admin_Settings::get_settings();
		$show_demo   = 'on' !== $wl['hide_demo_links'];
		$show_docs   = 'on' !== $wl['hide_docs_links'];
		$is_new      = self::resolve_new_widgets( $widget_info );
		$integration = self::integration_state();

		$widget_info = self::sort_categories( $widget_info );

		$categories = [];

		foreach ( $widget_info as $category_name => $widgets ) {
			if ( empty( $widgets ) ) {
				continue;
			}

			$items = [];

			foreach ( $widgets as $widget_key => $widget_data ) {
				$name   = isset( $widget_data['name'] ) ? $widget_data['name'] : '';
				$is_pro = ! empty( $widget_data['is_pro'] );

				/*
				 * A widget this build ships has to be in the master list, which
				 * is what a save is validated against. A widget it does not
				 * ship is listed but never switchable, so the master list has
				 * no say over it — the free edition carries the paid widgets
				 * here so the library reads as one library rather than two.
				 */
				if ( '' === $name || ( ! $is_pro && ! isset( $all_modules[ $name ] ) ) ) {
					continue;
				}

				$section = isset( $widget_data['integration'] ) ? $widget_data['integration'] : '';

				$items[] = [
					'name'        => $name,
					'title'       => self::plain_text( isset( $widget_data['title'] ) ? $widget_data['title'] : ucfirst( $widget_key ) ),
					'icon'        => isset( $widget_data['icon'] ) ? $widget_data['icon'] : '',
					'demo'        => $show_demo && ! empty( $widget_data['demo'] ) ? $widget_data['demo'] : '',
					'docs'        => $show_docs && ! empty( $widget_data['docs'] ) ? $widget_data['docs'] : '',
					'enabled'     => ! $is_pro && isset( $enabled[ $name ] ),
					'isPro'       => $is_pro,
					'isNew'       => ! empty( $is_new[ $name ] ),
					'integration' => isset( $integration[ $section ] ) ? $integration[ $section ] : null,
				];
			}

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

			$categories[] = [
				'name'    => self::plain_text( $category_name ),
				'slug'    => sanitize_title( $category_name ),
				'widgets' => $items,
			];
		}

		return rest_ensure_response( [
			'categories' => $categories,
			'stats'      => pp_get_modules_stats(),
		] );
	}

	/**
	 * The Integration panel's credential sections, as the catalogue sees them.
	 *
	 * Keyed by the slug a widget names in its 'integration' key, which is also
	 * the slug the Integration panel gives that section — the two halves of the
	 * link, one in PHP and one in panels.js. Renaming a slug means renaming it
	 * in both places or the link lands on the panel without scrolling anywhere.
	 *
	 * 'groups' mirrors how the section is drawn: each inner array is one set of
	 * credentials that has to be complete to be usable, and any one of them
	 * being complete configures the section. A reCAPTCHA site key on its own
	 * does nothing, so it counts for nothing until the secret is there too.
	 *
	 * 'required' is whether the widget is inert without it. Google Maps draws
	 * nothing usable without a key; a Login Form without a Facebook app is just
	 * a login form. Only the first kind is worth flagging on an untouched site,
	 * which is why the other still gets a link but never a warning.
	 *
	 * CSV Upload is deliberately absent. It is a switch rather than a
	 * credential, and off is a perfectly good state for it to be in.
	 *
	 * @since 3.0.1
	 * @return array Slug => [ required, groups ].
	 */
	private static function integration_groups() {
		return [
			'login-form'       => [
				'required' => false,
				'groups'   => [
					[ 'pp_fb_app_id', 'pp_fb_app_secret' ],
					[ 'pp_google_client_id' ],
				],
			],
			'google-maps'      => [
				'required' => true,
				'groups'   => [ [ 'google_map_api' ] ],
			],
			'business-reviews' => [
				'required' => true,
				'groups'   => [
					[ 'pp_google_places_api_key' ],
					[ 'pp_yelp_api_key' ],
				],
			],
			'event-calendar'   => [
				'required' => true,
				'groups'   => [ [ 'pp_google_calendar_api' ] ],
			],
			'instagram-feed'   => [
				'required' => true,
				'groups'   => [ [ 'pp_instagram_access_token' ] ],
			],

			// The gallery plays anything added by hand; the key is only for
			// pulling a playlist or a channel.
			'video-gallery'    => [
				'required' => false,
				'groups'   => [ [ 'pp_youtube_api_key' ] ],
			],
			'recaptcha'        => [
				'required' => false,
				'groups'   => [
					[ 'pp_recaptcha_site_key', 'pp_recaptcha_secret_key' ],
					[ 'pp_recaptcha_v3_site_key', 'pp_recaptcha_v3_secret_key' ],
				],
			],
		];
	}

	/**
	 * Whether each integration section has been filled in.
	 *
	 * Only ever booleans: the catalogue is readable by anyone who may edit
	 * posts, while the credentials themselves are behind manage_options, so a
	 * user who cannot open the Integration panel is told nothing about it and
	 * offered no link to it. The same goes for a white labelled install that
	 * has hidden the panel — there is nowhere for the link to go.
	 *
	 * @since 3.0.1
	 * @return array Slug => [ section, configured, required ], empty when the
	 *               panel is out of reach.
	 */
	private static function integration_state() {
		$wl = PP_Admin_Settings::get_settings();

		if ( 'on' === $wl['hide_integration_tab'] ) {
			return [];
		}

		$state = [];

		foreach ( self::integration_groups() as $slug => $section ) {
			$keys = array_merge( ...$section['groups'] );

			foreach ( $keys as $key ) {
				if ( ! PP_Settings_Registry::current_user_can( $key ) ) {
					continue 2;
				}
			}

			$configured = false;

			foreach ( $section['groups'] as $group ) {
				$complete = true;

				foreach ( $group as $key ) {
					if ( '' === trim( (string) PP_Settings_Registry::read( $key ) ) ) {
						$complete = false;
						break;
					}
				}

				if ( $complete ) {
					$configured = true;
					break;
				}
			}

			$state[ $slug ] = [
				'section'    => $slug,
				'configured' => $configured,
				'required'   => $section['required'],
			];
		}

		return $state;
	}

	/**
	 * Order the catalogue groups for display.
	 *
	 * Alphabetical, with the exception that a group can be pinned to sit
	 * directly after another one. The builder widgets read as a continuation of
	 * the store widgets, so they follow them instead of sorting ahead of them on
	 * the B. A group added through the pp_elements_widget_info filter is not
	 * pinned and takes its alphabetical place among the rest.
	 *
	 * @since 3.0.0
	 * @param array $widget_info Widget catalogue from PP_Config.
	 * @return array The same catalogue, ordered.
	 */
	private static function sort_categories( $widget_info ) {
		ksort( $widget_info );

		// Group name => the group it belongs immediately after.
		$follows = [
			'WooCommerce Builder Elements' => 'WooCommerce Elements',
		];

		foreach ( $follows as $group => $anchor ) {
			if ( ! isset( $widget_info[ $group ] ) || ! isset( $widget_info[ $anchor ] ) ) {
				continue;
			}

			$widgets = $widget_info[ $group ];
			unset( $widget_info[ $group ] );

			$position    = array_search( $anchor, array_keys( $widget_info ), true ) + 1;
			$widget_info = array_merge(
				array_slice( $widget_info, 0, $position, true ),
				[ $group => $widgets ],
				array_slice( $widget_info, $position, null, true )
			);
		}

		return $widget_info;
	}

	/**
	 * Which widgets still count as new on this site.
	 *
	 * A widget is flagged 'is_new' in PP_Config for the release that introduces
	 * it, and the badge runs for NEW_BADGE_PERIOD from the moment this site
	 * first saw it. Anchoring to the site rather than to a release date is what
	 * makes the badge survive a late update — the case it exists for, since a
	 * new widget is switched off on every site that already has a saved widget
	 * list, and this screen is the only place to switch it on.
	 *
	 * The stamp is taken here, on the read that paints the Elements screen,
	 * because a widget nobody has seen yet has nothing to date from. That makes
	 * this a write on a GET, but only on the first request after a release
	 * introduces a widget, and only for an administrator already on the screen.
	 *
	 * @since 3.0.0
	 * @param array $widget_info Widget catalogue from PP_Config.
	 * @return array Map of widget name => whether the badge is still running.
	 */
	private static function resolve_new_widgets( $widget_info ) {
		$seen   = get_option( self::NEW_SEEN_OPTION, [] );
		$seen   = is_array( $seen ) ? $seen : [];
		$now    = time();
		$stamps = [];
		$is_new = [];

		foreach ( $widget_info as $widgets ) {
			foreach ( (array) $widgets as $widget_data ) {
				if ( empty( $widget_data['is_new'] ) || empty( $widget_data['name'] ) ) {
					continue;
				}

				$name = $widget_data['name'];

				$stamps[ $name ] = isset( $seen[ $name ] ) ? (int) $seen[ $name ] : $now;
				$is_new[ $name ] = ( $now - $stamps[ $name ] ) < self::NEW_BADGE_PERIOD;
			}
		}

		/*
		 * Written back only when the flagged set has actually changed, so this
		 * is one write per release rather than one per request. Rebuilding the
		 * map from the flags instead of merging into it also drops the stamps
		 * of widgets that are no longer flagged, so the option cannot grow
		 * without bound.
		 */
		if ( $stamps !== $seen ) {
			update_option( self::NEW_SEEN_OPTION, $stamps, false );
		}

		return $is_new;
	}

	/**
	 * Which widgets actually appear on this site.
	 *
	 * Separate from GET /modules on purpose. Answering it means loading every
	 * Elementor document and walking its element tree, which is far too slow to
	 * sit on the path that paints the screen. The client asks for it in the
	 * background once the catalogue is up, and the answer is cached so the next
	 * visit is instant.
	 *
	 * @since 3.0.0
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response
	 */
	public static function get_module_usage( $request ) {
		if ( ! $request->get_param( 'refresh' ) ) {
			$cached = get_transient( self::USAGE_CACHE_KEY );

			if ( is_array( $cached ) ) {
				$cached['cached'] = true;

				return rest_ensure_response( $cached );
			}
		}

		$all = array_keys( pp_get_modules() );

		// Reading documents needs Elementor. Without it, report the whole
		// library as unclassified rather than as unused, which would be a lie.
		if ( ! did_action( 'elementor/loaded' ) || ! class_exists( '\Elementor\Plugin' ) ) {
			return rest_ensure_response( [
				'available' => false,
				'used'      => [],
				'notUsed'   => [],
				'cached'    => false,
			] );
		}

		$used = array_values( array_intersect( $all, array_keys( (array) pp_get_filter_modules( 'used' ) ) ) );

		$payload = [
			'available' => true,
			'used'      => $used,
			'notUsed'   => array_values( array_diff( $all, $used ) ),
			'cached'    => false,
		];

		set_transient( self::USAGE_CACHE_KEY, $payload, self::USAGE_CACHE_TTL );

		return rest_ensure_response( $payload );
	}

	/**
	 * Undo HTML escaping for a label headed into a JSON response.
	 *
	 * PP_Config escapes widget and category titles at definition time, so
	 * "Info Grid & Carousel" is stored as "Info Grid &amp; Carousel". The old
	 * template escaped that a second time and rendered the entity verbatim; a
	 * JSON client has the same problem for the opposite reason. Escaping is the
	 * consumer's job, so the API hands over plain text.
	 *
	 * @since 3.0.0
	 * @param string $text Possibly escaped label.
	 * @return string
	 */
	private static function plain_text( $text ) {
		return html_entity_decode( (string) $text, ENT_QUOTES, get_bloginfo( 'charset' ) );
	}

	/* ---------------------------------------------------------------------
	 * License
	 * ------------------------------------------------------------------ */

	/**
	 * Current license state.
	 *
	 * The stored status is returned without contacting the store. The legacy
	 * screen made a blocking remote call on every page load; pass refresh=1 to
	 * ask for that explicitly.
	 *
	 * @since 3.0.0
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response
	 */
	public static function get_license( $request ) {
		if ( $request->get_param( 'refresh' ) ) {
			$checked = pp_elements_check_license();

			if ( is_array( $checked ) && isset( $checked['message'] ) ) {
				return rest_ensure_response( array_merge(
					self::license_payload(),
					[ 'error' => PP_Admin_Settings::parse_error( $checked['message'] ) ]
				) );
			}

			if ( is_string( $checked ) && '' !== $checked ) {
				pp_elements_update( 'pp_license_status', in_array( $checked, [ 'site_inactive', 'invalid', 'expired' ], true ) ? '' : $checked );
			}
		}

		return rest_ensure_response( self::license_payload() );
	}

	/**
	 * Activate a license key.
	 *
	 * @since 3.0.0
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public static function activate_license( $request ) {
		if ( defined( 'PP_ELEMENTS_LICENSE_KEY' ) ) {
			return new \WP_Error(
				'pp_rest_license_constant',
				esc_html__( 'The license key is defined in wp-config.php and cannot be changed here.', 'powerpack' ),
				[ 'status' => 400 ]
			);
		}

		$key    = trim( (string) $request->get_param( 'key' ) );
		$stored = pp_elements_get( 'pp_license_key' );

		/*
		 * The stored key never reaches the browser in full — only a mask — so
		 * an empty key means "activate the one already saved". Deactivating
		 * leaves the key in place, and without this there would be no way to
		 * activate it again short of pasting it from the store.
		 */
		if ( '' === $key && $stored ) {
			$key = $stored;
		}

		if ( '' === $key ) {
			return new \WP_Error(
				'pp_rest_license_empty',
				esc_html__( 'Enter a license key.', 'powerpack' ),
				[ 'status' => 400 ]
			);
		}

		// A new key invalidates the stored status, matching the legacy handler.

		if ( $stored && $stored !== $key ) {
			pp_elements_delete( 'pp_license_status' );
		}

		pp_elements_update( 'pp_license_key', $key );

		$response = pp_elements_license( 'activate_license', $key );
		$error    = self::remote_error( $response );

		if ( $error ) {
			return new \WP_Error( 'pp_rest_license_http', $error, [ 'status' => 502 ] );
		}

		$data = json_decode( wp_remote_retrieve_body( $response ) );

		if ( ! is_object( $data ) ) {
			return new \WP_Error(
				'pp_rest_license_response',
				esc_html__( 'The store returned an unreadable response.', 'powerpack' ),
				[ 'status' => 502 ]
			);
		}

		if ( isset( $data->success ) && false === $data->success ) {
			$message = isset( $data->error ) ? pp_elements_license_messages( $data->error ) : esc_html__( 'The license could not be activated.', 'powerpack' );

			return rest_ensure_response( array_merge(
				self::license_payload(),
				[
					'activated' => false,
					'error'     => $message,
				]
			) );
		}

		pp_elements_update( 'pp_license_status', isset( $data->license ) ? $data->license : '' );
		pp_elements_update( 'pp_license_user_action', 'activated' );

		if ( ! empty( $data->expires ) ) {
			pp_elements_update( 'pp_license_expires', $data->expires );
		}

		return rest_ensure_response( array_merge( self::license_payload(), [ 'activated' => true ] ) );
	}

	/**
	 * Deactivate the stored license key.
	 *
	 * @since 3.0.0
	 * @return \WP_REST_Response|\WP_Error
	 */
	public static function deactivate_license() {
		$response = pp_elements_license( 'deactivate_license', pp_elements_get_license_key() );
		$error    = self::remote_error( $response );

		if ( $error ) {
			return new \WP_Error( 'pp_rest_license_http', $error, [ 'status' => 502 ] );
		}

		$data = json_decode( wp_remote_retrieve_body( $response ) );

		if ( is_object( $data ) && isset( $data->license ) && in_array( $data->license, [ 'deactivated', 'failed' ], true ) ) {
			pp_elements_delete( 'pp_license_status' );
			pp_elements_delete( 'pp_license_expires' );
		}

		pp_elements_update( 'pp_license_user_action', 'deactivated' );

		// The rollback list is cached under the licence key, so it survives a
		// deactivation that has just taken away the right to download any of it.
		pp_elements_clear_rollback_versions_cache( PP_Rollback::VERSION_LIMIT );

		return rest_ensure_response( array_merge( self::license_payload(), [ 'deactivated' => true ] ) );
	}

	/**
	 * Forget the stored license key.
	 *
	 * Deactivating leaves the key in place so it can be activated again without
	 * fetching it from the store; this is the way back out of that. Only the
	 * local record is removed — the seat at the store is released by
	 * deactivation, which is attempted first for a key that may still hold one.
	 *
	 * @since 3.0.0
	 * @return \WP_REST_Response|\WP_Error
	 */
	public static function remove_license() {
		if ( defined( 'PP_ELEMENTS_LICENSE_KEY' ) ) {
			return new \WP_Error(
				'pp_rest_license_constant',
				esc_html__( 'The license key is defined in wp-config.php and cannot be changed here.', 'powerpack' ),
				[ 'status' => 400 ]
			);
		}

		$key    = (string) pp_elements_get( 'pp_license_key' );
		$status = (string) pp_elements_get( 'pp_license_status' );

		/*
		 * An expired key can still be registered against this site, and once the
		 * key is gone there is nothing left to deactivate with. The result is
		 * ignored: a store that cannot be reached must not trap the key here.
		 */
		if ( '' !== $key && '' !== $status && 'inactive' !== $status ) {
			pp_elements_license( 'deactivate_license', $key );
		}

		pp_elements_delete( 'pp_license_key' );
		pp_elements_delete( 'pp_license_status' );
		pp_elements_delete( 'pp_license_expires' );
		pp_elements_delete( 'pp_license_user_action' );

		// Cached under the licence key, so it outlives the key without this.
		pp_elements_clear_rollback_versions_cache( PP_Rollback::VERSION_LIMIT );

		return rest_ensure_response( array_merge( self::license_payload(), [ 'removed' => true ] ) );
	}

	/**
	 * Describe the stored license state.
	 *
	 * @since 3.0.0
	 * @return array
	 */
	private static function license_payload() {
		$key     = (string) pp_elements_get( 'pp_license_key' );
		$status  = (string) pp_elements_get( 'pp_license_status' );
		$expires = pp_elements_get( 'pp_license_expires' );

		if ( '' === $status ) {
			$status = 'inactive';
		}

		$payload = [
			'key'          => PP_Settings_Registry::mask( $key ),
			'hasKey'       => '' !== $key,
			'fromConstant' => defined( 'PP_ELEMENTS_LICENSE_KEY' ),
			'status'       => $status,
			'expires'      => $expires ? $expires : '',
			'expiresLabel' => '',
			'daysLeft'     => null,
			'lifetime'     => 'lifetime' === $expires,
		];

		if ( $expires && 'lifetime' !== $expires ) {
			$timestamp = strtotime( $expires );

			if ( $timestamp ) {
				$payload['expiresLabel'] = date_i18n( get_option( 'date_format' ), $timestamp );
				$payload['daysLeft']     = (int) ceil( ( $timestamp - time() ) / DAY_IN_SECONDS );
			}
		}

		return $payload;
	}

	/* ---------------------------------------------------------------------
	 * Rollback
	 * ------------------------------------------------------------------ */

	/**
	 * Versions this site can be rolled back to.
	 *
	 * A site that cannot roll back gets a reason rather than an error status:
	 * "your license is not active" and "this host has file modifications
	 * disabled" are both things the screen should say out loud, and neither is
	 * a failed request.
	 *
	 * @since 3.0.0
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response
	 */
	public static function get_rollback( $request ) {
		$payload = [
			'current'   => POWERPACK_ELEMENTS_VER,
			'available' => false,
			'error'     => '',
			'versions'  => [],
		];

		$allowed = PP_Rollback::can_run();

		if ( is_wp_error( $allowed ) ) {
			$payload['error'] = $allowed->get_error_message();

			return rest_ensure_response( $payload );
		}

		$files = PP_Rollback::get_files( (bool) $request->get_param( 'refresh' ) );

		if ( is_wp_error( $files ) ) {
			$payload['error'] = $files->get_error_message();

			return rest_ensure_response( $payload );
		}

		foreach ( $files as $file ) {
			/*
			 * The download URL is deliberately not in this payload. The browser
			 * names a package by key and the server resolves the URL from the
			 * store's own response, so nothing the client can send decides what
			 * gets unpacked over the plugin directory.
			 */
			$payload['versions'][] = [
				'key'       => $file['file_key'],
				'label'     => $file['name'],
				'version'   => isset( $file['version'] ) ? $file['version'] : '',
				'isCurrent' => ! empty( $file['version'] ) && POWERPACK_ELEMENTS_VER === $file['version'],
			];
		}

		$payload['available'] = ! empty( $payload['versions'] );

		return rest_ensure_response( $payload );
	}

	/**
	 * Install one of those versions.
	 *
	 * @since 3.0.0
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public static function run_rollback( $request ) {
		$result = PP_Rollback::run( $request->get_param( 'file_key' ) );

		if ( is_wp_error( $result ) ) {
			$status = in_array(
				$result->get_error_code(),
				[ 'pp_rollback_forbidden', 'pp_rollback_network', 'pp_rollback_file_mods_disabled' ],
				true
			) ? rest_authorization_required_code() : 500;

			$result->add_data( [ 'status' => $status ] );

			return $result;
		}

		return rest_ensure_response( $result );
	}

	/* ---------------------------------------------------------------------
	 * Credential verification
	 * ------------------------------------------------------------------ */

	/**
	 * Check a credential against its provider without storing anything.
	 *
	 * Verification is decoupled from saving on purpose. A failed verdict is
	 * information for the user, never a mutation: it can neither block the save
	 * nor blank the stored key.
	 *
	 * @since 3.0.0
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response
	 */
	public static function verify_credential( $request ) {
		$field = $request->get_param( 'field' );
		$value = $request->get_param( 'value' );

		if ( null === $value || '' === $value ) {
			$value = (string) PP_Settings_Registry::read( $field );
		}

		$value = trim( (string) $value );

		if ( '' === $value ) {
			return rest_ensure_response( [
				'field'   => $field,
				'valid'   => null,
				'message' => esc_html__( 'Nothing to verify.', 'powerpack' ),
				'cached'  => false,
			] );
		}

		$cache_key = 'pp_verify_' . md5( $field . '|' . $value );
		$cached    = get_transient( $cache_key );

		if ( is_array( $cached ) ) {
			$cached['cached'] = true;

			return rest_ensure_response( $cached );
		}

		if ( 'pp_fb_app_id' === $field ) {
			$result = self::verify_facebook_app_id( $value );
		} else {
			$result = self::verify_google_places_key( $value );
		}

		$result['field']  = $field;
		$result['cached'] = false;

		// Only cache a definite verdict. A transport failure says nothing about
		// the credential and must not stick.
		if ( null !== $result['valid'] ) {
			set_transient( $cache_key, $result, self::VERIFY_CACHE_TTL );
		}

		return rest_ensure_response( $result );
	}

	/**
	 * Verify a Facebook App ID.
	 *
	 * @since 3.0.0
	 * @param string $value App ID.
	 * @return array
	 */
	private static function verify_facebook_app_id( $value ) {
		$response = wp_remote_get( 'https://graph.facebook.com/' . rawurlencode( $value ) );

		if ( is_wp_error( $response ) ) {
			return [
				'valid'   => null,
				'message' => $response->get_error_message(),
			];
		}

		if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
			/*
			 * Graph answers an unknown app id with 400 and the message "An
			 * access token is required to request this resource", which is
			 * about its own request rather than the id and reads as though the
			 * fault is at this end. Passing either that or the bare HTTP status
			 * on to the user — this said "Error: Bad Request" — tells them
			 * nothing they can act on.
			 *
			 * A published app answers 200 with its details and no token, so a
			 * 400 usually does mean the id is wrong. Usually, not always: an
			 * app still in development answers the same way, and the reply says
			 * which case this is.
			 */
			return [
				'valid'   => false,
				'message' => esc_html__( 'Facebook did not recognise this App ID. Check it for typos. An app that has not been published yet cannot be checked from here.', 'powerpack' ),
			];
		}

		return [
			'valid'   => true,
			'message' => esc_html__( 'Facebook App ID verified.', 'powerpack' ),
		];
	}

	/**
	 * Verify a Google Places API key.
	 *
	 * @since 3.0.0
	 * @param string $value API key.
	 * @return array
	 */
	private static function verify_google_places_key( $value ) {
		$url = add_query_arg(
			[
				'key'     => rawurlencode( $value ),
				'placeid' => 'ChIJjZwurGTlZzkRLVOKFH8rKYc',
			],
			'https://maps.googleapis.com/maps/api/place/details/json'
		);

		$response = wp_remote_get( $url, [ 'timeout' => 15 ] );

		if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
			return [
				'valid'   => null,
				'message' => is_wp_error( $response ) ? $response->get_error_message() : esc_html__( 'Could not reach the Google Places API.', 'powerpack' ),
			];
		}

		$body   = json_decode( wp_remote_retrieve_body( $response ) );
		$status = is_object( $body ) && isset( $body->status ) ? $body->status : '';

		if ( 'OK' === $status ) {
			return [
				'valid'   => true,
				'message' => esc_html__( 'Google Places API Key verified.', 'powerpack' ),
			];
		}

		if ( 'OVER_QUERY_LIMIT' === $status ) {
			return [
				'valid'   => null,
				'message' => esc_html__( 'The key could not be checked right now: the Google Places quota is exhausted.', 'powerpack' ),
			];
		}

		return [
			'valid'   => false,
			/* translators: %s: status returned by the Google Places API. */
			'message' => sprintf( esc_html__( 'Google Places API Key is invalid. Error: %s', 'powerpack' ), $status ),
		];
	}

	/**
	 * Describe a transport level failure, if any.
	 *
	 * @since 3.0.0
	 * @param array|\WP_Error $response Remote response.
	 * @return string Empty when the response is usable.
	 */
	private static function remote_error( $response ) {
		if ( is_wp_error( $response ) ) {
			return $response->get_error_message();
		}

		if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
			return esc_html__( 'An error occurred, please try again.', 'powerpack' );
		}

		return '';
	}

	/* ---------------------------------------------------------------------
	 * Welcome
	 * ------------------------------------------------------------------ */

	/**
	 * Content for the Welcome panel: recent releases and recent posts.
	 *
	 * Both are read here rather than in the browser — the changelog is a file
	 * on disk, and the blog lives on another origin, so a client side fetch
	 * would be blocked. The remote call is cached for twelve hours, under the
	 * same key the previous screen used.
	 *
	 * @since 3.0.0
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response
	 */
	public static function get_welcome( $request ) {
		/*
		 * The changelog is a local file; the posts are a remote request that can
		 * take seconds on a cold cache. A caller that only wants release notes
		 * should not wait for the blog, so it can opt out.
		 */
		$posts = $request->get_param( 'posts' ) ? self::blog_posts() : [];

		return rest_ensure_response( [
			'changelog' => self::changelog_entries( 2 ),
			'posts'     => $posts,
		] );
	}

	/**
	 * Hide the setup checklist for the current user.
	 *
	 * Per user rather than per site: one administrator deciding they are done
	 * with the checklist should not take it away from their colleagues.
	 *
	 * @since 3.0.0
	 * @return \WP_REST_Response
	 */
	public static function dismiss_setup() {
		update_user_meta( get_current_user_id(), self::SETUP_DISMISSED_META, 1 );

		return rest_ensure_response( [ 'dismissed' => true ] );
	}

	/**
	 * The most recent releases, parsed from changelog.txt.
	 *
	 * Reading the shipped file keeps the panel honest: the alternative is
	 * hand-written release notes in a template, which go stale the first time
	 * someone forgets to update them.
	 *
	 * @since 3.0.0
	 * @param int $limit How many releases to return.
	 * @return array
	 */
	private static function changelog_entries( $limit = 2 ) {
		$path = POWERPACK_ELEMENTS_PATH . 'changelog.txt';

		if ( ! is_readable( $path ) ) {
			return [];
		}

		$lines   = explode( "\n", (string) file_get_contents( $path ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$entries = [];
		$current = null;

		foreach ( $lines as $line ) {
			$line = trim( $line );

			if ( preg_match( '/^==\s*(.+?)\s*==$/', $line, $matches ) ) {
				if ( $current ) {
					$entries[] = $current;

					if ( count( $entries ) >= $limit ) {
						return $entries;
					}
				}

				$current = [
					'version' => $matches[1],
					'date'    => '',
					'items'   => [],
				];

				continue;
			}

			if ( ! $current ) {
				continue;
			}

			if ( preg_match( '/^Release date:\s*(.*)$/i', $line, $matches ) ) {
				$current['date'] = trim( $matches[1] );

				continue;
			}

			if ( 0 !== strpos( $line, '*' ) ) {
				continue;
			}

			$text = trim( ltrim( $line, '*' ) );

			/*
			 * The prefix is the plugin's own convention and it is what the
			 * coloured dot in front of each line means. It is not one word per
			 * kind: the file has used "Fix" and "Fixed" interchangeably for
			 * years, and "Added" as often as "New".
			 */
			$type = 'other';

			if ( preg_match( '/^([A-Za-z]+)\s*:/', $text, $matches ) ) {
				$label = strtolower( $matches[1] );

				if ( in_array( $label, [ 'new', 'added' ], true ) ) {
					$type = 'new';
				} elseif ( in_array( $label, [ 'enhancement', 'tweak' ], true ) ) {
					$type = 'enhancement';
				} elseif ( in_array( $label, [ 'fix', 'fixed', 'hotfix' ], true ) ) {
					$type = 'fix';
				}
			}

			$current['items'][] = [
				'type' => $type,
				'text' => $text,
			];
		}

		if ( $current && count( $entries ) < $limit ) {
			$entries[] = $current;
		}

		return $entries;
	}

	/**
	 * Recent posts from the plugin's site.
	 *
	 * Failure is silent and cached as an empty list is not: a site that cannot
	 * reach the blog should not retry on every page load, but it should
	 * recover once it can.
	 *
	 * @since 3.0.0
	 * @return array
	 */
	private static function blog_posts() {
		$cached = get_transient( 'pp_elements_welcome_posts' );

		if ( is_array( $cached ) ) {
			return $cached;
		}

		$response = wp_remote_get(
			'https://powerpackelements.com/wp-json/wp/v2/posts?per_page=2&_fields=id,title,link,excerpt,date,_links,_embedded&_embed=wp:featuredmedia',
			[ 'timeout' => 10 ]
		);

		if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
			return [];
		}

		$body = json_decode( wp_remote_retrieve_body( $response ), true );

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

		$posts = [];

		foreach ( $body as $post ) {
			$media = isset( $post['_embedded']['wp:featuredmedia'][0] ) ? $post['_embedded']['wp:featuredmedia'][0] : [];
			$thumb = isset( $media['media_details']['sizes']['medium']['source_url'] )
				? $media['media_details']['sizes']['medium']['source_url']
				: ( isset( $media['source_url'] ) ? $media['source_url'] : '' );

			$posts[] = [
				'title'   => isset( $post['title']['rendered'] ) ? self::plain_text( $post['title']['rendered'] ) : '',
				'link'    => isset( $post['link'] ) ? esc_url_raw( $post['link'] ) : '',
				'excerpt' => isset( $post['excerpt']['rendered'] ) ? wp_trim_words( self::plain_text( wp_strip_all_tags( $post['excerpt']['rendered'] ) ), 20, '…' ) : '',
				'date'    => isset( $post['date'] ) ? date_i18n( get_option( 'date_format' ), strtotime( $post['date'] ) ) : '',
				'thumb'   => $thumb ? esc_url_raw( $thumb ) : '',
			];
		}

		set_transient( 'pp_elements_welcome_posts', $posts, 12 * HOUR_IN_SECONDS );

		return $posts;
	}

	/* ---------------------------------------------------------------------
	 * Templates
	 * ------------------------------------------------------------------ */

	/**
	 * Selectable pages and Elementor templates for the template pickers.
	 *
	 * @since 3.0.0
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response
	 */
	public static function get_templates( $request ) {
		$type = $request->get_param( 'type' );

		if ( 'woo' === $type ) {
			$source = class_exists( __NAMESPACE__ . '\PP_Woo_Builder' ) ? PP_Woo_Builder::get_templates() : [];
		} elseif ( 'pages' === $type ) {
			$source = [
				'pages' => get_posts( [
					'post_type'              => 'page',
					'post_status'            => 'publish',
					'orderby'                => 'title',
					'order'                  => 'ASC',
					'posts_per_page'         => -1,
					'update_post_meta_cache' => false,
					'update_post_term_cache' => false,
				] ),
			];
		} else {
			$source = class_exists( __NAMESPACE__ . '\PP_Header_Footer' ) ? PP_Header_Footer::get_templates() : [];
		}

		return rest_ensure_response( [
			'type'   => $type,
			'groups' => self::normalize_templates( $source ),
		] );
	}

	/**
	 * Flatten a template source into labelled groups.
	 *
	 * Accepts either a flat list of posts or a map of group name to posts,
	 * since the header/footer and WooCommerce helpers differ.
	 *
	 * @since 3.0.0
	 * @param array $source Posts, or a map of group => posts.
	 * @return array
	 */
	private static function normalize_templates( $source ) {
		$labels = [
			'pages'     => esc_html__( 'Pages', 'powerpack' ),
			'templates' => esc_html__( 'Templates', 'powerpack' ),
		];

		$groups = [];
		$source = (array) $source;

		// A flat post list arrives with integer keys.
		if ( isset( $source[0] ) ) {
			$source = [ 'templates' => $source ];
		}

		foreach ( $source as $group => $posts ) {
			$items = [];

			foreach ( (array) $posts as $post ) {
				if ( ! is_object( $post ) || ! isset( $post->ID ) ) {
					continue;
				}

				$items[] = [
					'id'      => (int) $post->ID,
					'title'   => $post->post_title ? $post->post_title : sprintf( '#%d', $post->ID ),
					'editUrl' => admin_url( 'post.php?post=' . (int) $post->ID . '&action=elementor' ),
				];
			}

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

			$groups[] = [
				'key'   => $group,
				'label' => isset( $labels[ $group ] ) ? $labels[ $group ] : ucfirst( $group ),
				'items' => $items,
			];
		}

		return $groups;
	}
}

PP_Settings_REST_Controller::init();
