<?php
/**
 * Design Library — editor mount + image sideload REST endpoint.
 *
 * Two responsibilities:
 *
 * 1. Editor enqueue (`enqueue` method): registers the `design-library.js`
 *    bundle on `enqueue_block_editor_assets`. The bundle injects a
 *    launcher button into the editor's document tools + mounts the
 *    pattern modal.
 *
 * 2. Image sideload (`/blocksify-design-library/v1/sideload` REST):
 *    receives `{ url, alt }`, downloads the image server-side via
 *    `download_url()` + `media_handle_sideload()`, writes the
 *    originating URL onto the new attachment as `_blocksify_remote_src`
 *    meta, and returns the local attachment's source_url.
 *
 *    Why server-side: client-side `fetch()` is CORS-blocked for most
 *    CDN origins (picsum, Unsplash, etc.); the PHP HTTP API has no
 *    such constraint. The meta enables cross-session dedup — a second
 *    insert of the same pattern reuses the existing local copy.
 *
 * @package Blocksify
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

class Blocksify_Design_Library {

	const META_KEY               = '_blocksify_remote_src';
	const REST_NS                = 'blocksify-design-library/v1';
	const REST_SIDELOAD          = '/sideload';
	const REST_STUDIO            = '/studio';
	const REST_PATTERNS          = '/patterns';
	const REST_PATTERN_DETAIL    = '/patterns/(?P<id>[a-z0-9-]+)';
	const REST_PATTERN_PREVIEW   = '/patterns/(?P<id>[a-z0-9-]+)/preview';
	const CACHE_PREFIX           = 'bsy_dl_';
	const DEFAULT_CACHE_TTL      = 1800; // 30 minutes — catalog data is daily-ish so a longer TTL keeps the upstream load low. Per-kind override stays available via the `blocksify_design_library_cache_ttl` filter.
	const STUDIO_REST_SUFFIX     = '/wp-json/blocksify-design-studio/v1';

	/**
	 * Gate the Design Library subsystem behind `BLOCKSIFY_DESIGN_LIBRARY`.
	 *
	 * Default: HIDDEN. The catalog client, modal chassis, and REST sideload
	 * route ship inside the plugin but stay invisible on user sites until
	 * the cloud catalog API + Pro gating land + the feature is declared
	 * stable. When the gate is off, every hook below is skipped — no bundle
	 * enqueue, no REST route registration, no attachment meta — i.e., zero
	 * UI / REST surface on shipped sites.
	 *
	 * Enable on a site (any one of these):
	 *
	 *   - `define( 'BLOCKSIFY_DESIGN_LIBRARY', true );` in wp-config.php
	 *   - `add_filter( 'blocksify_design_library_enabled', '__return_true' );`
	 *     from a theme `functions.php` / mu-plugin / Code Snippets entry
	 *
	 * Distinct from `BLOCKSIFY_DEV` (Playground constant) on purpose:
	 * Playground is permanently dev-only, the Design Library will go GA.
	 * When that happens, flip the default to `true` (or drop the gate) —
	 * existing constant / filter consumers keep working as override switches
	 * (they'll DISABLE instead of ENABLE, matching the standard opt-out
	 * shape).
	 */
	public static function boot() {
		$enabled = defined( 'BLOCKSIFY_DESIGN_LIBRARY' )
			? (bool) BLOCKSIFY_DESIGN_LIBRARY
			: false;
		$enabled = (bool) apply_filters( 'blocksify_design_library_enabled', $enabled );
		if ( ! $enabled ) {
			return;
		}

		// Soft toggle (SPEC dashboard §8.2 Editor Enhancements panel) — when
		// the user sets `design_library_button` to `hidden`, skip every action
		// below so no bundle / REST route / meta is registered. Equivalent to
		// flipping the constant gate off, but driven from the Settings UI so
		// users don't have to touch wp-config.
		if ( 'hidden' === self::get_button_mode() ) {
			return;
		}

		add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'enqueue' ) );
		add_action( 'init', array( __CLASS__, 'register_meta' ) );
		add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
		// Wire into the dashboard's unified Clear-cache button. The
		// controller fires this action after wp_cache_flush(); subsystems
		// own their own delete and contribute to the cleared count via
		// the filter below. See SPEC-design-library-rest-api.md §11.6.
		add_action( 'blocksify_dashboard_clear_cache', array( __CLASS__, 'flush_cache' ) );
		add_filter( 'blocksify_dashboard_clear_cache_count', array( __CLASS__, 'contribute_cleared_count' ) );
	}

	/**
	 * Resolve the Design Library button placement setting.
	 *
	 * Returns one of `'toolbar' | 'sidebar' | 'hidden'`. Falls back to
	 * `'toolbar'` (preserves existing behavior) when the dashboard schema
	 * class hasn't loaded yet OR the stored value isn't a known mode.
	 *
	 * @return string
	 */
	public static function get_button_mode() {
		if ( ! class_exists( 'Blocksify_Dashboard_Schema' ) ) {
			return 'toolbar';
		}
		$settings = Blocksify_Dashboard_Schema::get_settings();
		$mode     = isset( $settings['editor_enhancements']['design_library_button'] )
			? (string) $settings['editor_enhancements']['design_library_button']
			: 'toolbar';
		return in_array( $mode, array( 'toolbar', 'sidebar', 'hidden' ), true ) ? $mode : 'toolbar';
	}

	public static function enqueue() {
		$asset_path = BLOCKSIFY_PATH . 'build/design-library.asset.php';
		if ( ! file_exists( $asset_path ) ) {
			return;
		}
		$asset = require $asset_path;
		wp_enqueue_script(
			'blocksify-design-library',
			BLOCKSIFY_URL . 'build/design-library.js',
			$asset['dependencies'],
			$asset['version'],
			true
		);
		wp_set_script_translations( 'blocksify-design-library', 'blocksify' );

		// Runtime config for the editor bundle.
		//
		// `proUrl`        — ProUpsell CTA href; disabled link when missing.
		// `previewStyles` — extra CSS each pattern card's `<BlockPreview>`
		//   iframe receives via the `additionalStyles` prop. Carries the
		//   custom-property declarations that Blocksify normally emits via
		//   `wp_head` (`--blocksify--spacing--*`, `--blocksify--font-size--*`,
		//   `--wp--preset--shadow--bsy-*`) + theme.json processed vars.
		//   Editor canvas gets these through inject-global-*.js's
		//   iframe-walking injector, but BlockPreview iframes mount fast
		//   enough that the MutationObserver-based path races; passing via
		//   `additionalStyles` is deterministic — WP writes them into the
		//   iframe head before first paint.
		wp_localize_script(
			'blocksify-design-library',
			'blocksifyDesignLibrary',
			array(
				'proUrl'        => defined( 'BLOCKSIFY_PRO_URL' ) ? BLOCKSIFY_PRO_URL : '',
				'previewStyles' => self::build_preview_styles(),
				// `buttonMode` drives the editor placement of the launcher
				// (`toolbar` portal vs. PluginSidebar `Fill`). When this is
				// `hidden`, boot() already short-circuited above so the bundle
				// would not be enqueued — JS only sees `toolbar` or `sidebar`.
				'buttonMode'    => self::get_button_mode(),
			)
		);

		$style_path = BLOCKSIFY_PATH . 'build/design-library.css';
		if ( file_exists( $style_path ) ) {
			wp_enqueue_style(
				'blocksify-design-library',
				BLOCKSIFY_URL . 'build/design-library.css',
				array( 'wp-components' ),
				(string) filemtime( $style_path )
			);
		}
	}

	/**
	 * Block names whose frontend stylesheets must ride along into the
	 * preview iframe's additionalStyles payload. WP's `settings.styles`
	 * already exposes editor styles for the post-editor canvas iframe,
	 * but `BlockPreview`'s iframe lives off a different pipeline —
	 * `additionalStyles` — and observable testing on WP 6.5+ shows
	 * `register_block_type`-registered frontend stylesheets DO NOT make
	 * it into that pipeline by default. Force-including them here gives
	 * each pattern card the same per-block layout rules (Section's
	 * inner clamp, Container's flex defaults, Image's figure shape, …)
	 * that drive correct max-width / spacing on the live page.
	 */
	const PREVIEW_BLOCK_STYLES = array(
		'section',
		'container',
		'button',
		'text',
		'icon',
		'image',
		'accordion',
		'accordion-item',
	);

	/**
	 * Editor-canvas root-container clamp, replicated for `<BlockPreview>`
	 * iframes.
	 *
	 * Background: WP's main post-editor canvas synthesizes a layout rule
	 * from `theme.json layout.contentSize` of the form:
	 *
	 *   .block-editor-block-list__layout.is-root-container
	 *     > :where(:not(.alignleft):not(.alignright):not(.alignfull)) {
	 *       max-width: <content-size>;
	 *       margin-{l,r}: auto !important;
	 *     }
	 *
	 * That rule is editor-only — frontend uses `.entry-content` selectors
	 * instead — and ships from a pipeline WP runs only for the editor's
	 * canvas iframe. `BlockPreview` iframes mount via the separate
	 * `additionalStyles` pipeline and therefore never see it, so the
	 * default-aligned top-level blocks in a pattern preview spread to
	 * the iframe edge instead of clamping to content-size the way they
	 * would inside the real editor canvas (or after insertion).
	 *
	 * Replicating the selector verbatim here keeps the preview byte-
	 * identical to the editor's clamp behavior. Pulls the value from
	 * `--wp--style--global--content-size` (which `wp_get_global_stylesheet`
	 * already emits into the iframe via the variables block) so the rule
	 * tracks theme.json updates without a PHP round-trip.
	 */
	const PREVIEW_ROOT_CONTAINER_CLAMP = <<<CSS
		.block-editor-block-list__layout.is-root-container
			> :where(:not(.alignleft):not(.alignright):not(.alignfull)) {
			max-width: var(--wp--style--global--content-size, 1200px);
			margin-left: auto !important;
			margin-right: auto !important;
		}
		CSS;

	/**
	 * Build the custom-property bundle each `<BlockPreview>` iframe needs
	 * so per-instance Blocksify CSS references resolve the way they would
	 * inside the editor canvas / on the live site.
	 *
	 * Four sources, concatenated in declaration order:
	 *
	 *   1. WP theme.json processed variables (`--wp--preset--color--*`,
	 *      `--wp--style--global--content-size`, …) via
	 *      `wp_get_global_stylesheet(['variables'])`. WP's settings.styles
	 *      already carries most of these for the post-editor's canvas
	 *      iframe, but `additionalStyles` on `BlockPreview` is a separate
	 *      pipeline — feeding the same vars here keeps preview parity.
	 *   2. Blocksify spacing / font-size token presets via
	 *      `Blocksify_Tokens::render_root_styles()` (output buffered to
	 *      strip the `<style>` wrapper).
	 *   3. Blocksify shadow presets via `Blocksify_Shadows::build_css()`.
	 *   4. Per-block frontend stylesheets for every block in
	 *      `PREVIEW_BLOCK_STYLES` — see that constant's docblock for why.
	 *
	 * Defensive: every source is gated on `function_exists` /
	 * `class_exists` so a partial Blocksify install or non-block theme
	 * degrades gracefully rather than fatal-ing.
	 *
	 * @return string Concatenated CSS, ready to drop inside a `<style>`.
	 */
	private static function build_preview_styles() {
		$parts = array();

		if ( function_exists( 'wp_get_global_stylesheet' ) ) {
			$theme_vars = (string) wp_get_global_stylesheet( array( 'variables' ) );
			if ( '' !== $theme_vars ) {
				$parts[] = $theme_vars;
			}
		}

		if ( class_exists( 'Blocksify_Tokens' )
			&& method_exists( 'Blocksify_Tokens', 'render_root_styles' ) ) {
			ob_start();
			Blocksify_Tokens::render_root_styles();
			$buffer = (string) ob_get_clean();
			if ( preg_match_all( '#<style[^>]*>(.*?)</style>#s', $buffer, $matches ) ) {
				$parts[] = implode( "\n", $matches[1] );
			}
		}

		if ( class_exists( 'Blocksify_Shadows' )
			&& method_exists( 'Blocksify_Shadows', 'build_css' ) ) {
			$shadow_css = (string) Blocksify_Shadows::build_css();
			if ( '' !== $shadow_css ) {
				$parts[] = $shadow_css;
			}
		}

		foreach ( self::PREVIEW_BLOCK_STYLES as $name ) {
			$path = BLOCKSIFY_PATH . 'build/blocks/' . $name . '/blocksify-' . $name . '.css';
			if ( file_exists( $path ) ) {
				$parts[] = (string) file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
			}
		}

		// Replicate the editor canvas's root-container clamp so default-
		// aligned top-level blocks (Section / Container / etc.) match
		// editor-canvas behavior — see constant docblock.
		$parts[] = self::PREVIEW_ROOT_CONTAINER_CLAMP;

		return trim( implode( "\n", $parts ) );
	}

	/**
	 * Register the meta on attachment posts. Stored when a sideload runs
	 * + read for dedup on subsequent sideload requests. `show_in_rest`
	 * lets the attachment edit screen surface the originating URL for
	 * debug visibility — REST writes go through the sideload endpoint,
	 * which calls `update_post_meta` server-side, not via REST meta.
	 */
	public static function register_meta() {
		register_post_meta( 'attachment', self::META_KEY, array(
			'type'              => 'string',
			'single'            => true,
			'show_in_rest'      => true,
			'sanitize_callback' => 'esc_url_raw',
			'auth_callback'     => function () {
				return current_user_can( 'upload_files' );
			},
		) );
	}

	public static function register_rest_routes() {
		register_rest_route(
			self::REST_NS,
			self::REST_SIDELOAD,
			array(
				'methods'             => 'POST',
				'permission_callback' => array( __CLASS__, 'sideload_permission' ),
				'args'                => array(
					'url' => array(
						'required' => true,
						'type'     => 'string',
						'format'   => 'uri',
					),
					'alt' => array(
						'required' => false,
						'type'     => 'string',
						'default'  => '',
					),
				),
				'callback'            => array( __CLASS__, 'sideload_callback' ),
			)
		);

		// Proxy routes — forward to Studio (B) with WP transient cache.
		// See docs/SPEC-design-library-rest-api.md §2.5–2.9 + §11.

		register_rest_route(
			self::REST_NS,
			self::REST_STUDIO,
			array(
				'methods'             => 'GET',
				'permission_callback' => array( __CLASS__, 'catalog_permission' ),
				'callback'            => array( __CLASS__, 'studio_callback' ),
			)
		);

		register_rest_route(
			self::REST_NS,
			self::REST_PATTERNS,
			array(
				'methods'             => 'GET',
				'permission_callback' => array( __CLASS__, 'catalog_permission' ),
				'args'                => array(
					'category'    => array(
						'required'          => false,
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_key',
					),
					'theme'       => array(
						'required'          => false,
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_key',
					),
					'search'      => array(
						'required'          => false,
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					),
					'page'        => array(
						'required' => false,
						'type'     => 'integer',
						'default'  => 1,
						'minimum'  => 1,
						'maximum'  => 1000,
					),
					'per_page'    => array(
						'required' => false,
						'type'     => 'integer',
						'default'  => 24,
						'minimum'  => 1,
						'maximum'  => 60,
					),
					'sort'        => array(
						'required' => false,
						'type'     => 'string',
						'default'  => 'popular',
						'enum'     => array( 'popular', 'newest', 'title_asc', 'title_desc' ),
					),
					'include_pro' => array(
						'required' => false,
						'type'     => 'boolean',
						'default'  => true,
					),
				),
				'callback'            => array( __CLASS__, 'patterns_callback' ),
			)
		);

		register_rest_route(
			self::REST_NS,
			self::REST_PATTERN_DETAIL,
			array(
				'methods'             => 'GET',
				'permission_callback' => array( __CLASS__, 'catalog_permission' ),
				'args'                => array(
					'id' => array(
						'required'          => true,
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_key',
					),
				),
				'callback'            => array( __CLASS__, 'pattern_detail_callback' ),
			)
		);

		register_rest_route(
			self::REST_NS,
			self::REST_PATTERN_PREVIEW,
			array(
				'methods'             => 'POST',
				'permission_callback' => array( __CLASS__, 'catalog_permission' ),
				'args'                => array(
					'id' => array(
						'required'          => true,
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_key',
					),
				),
				'callback'            => array( __CLASS__, 'pattern_preview_callback' ),
			)
		);
	}

	public static function sideload_permission() {
		// Sideload requires media upload capability — matches `wp.media`
		// and `wp_handle_upload` baseline. Subscribers / readers can't
		// fire this even if they reach the editor through a custom role.
		return current_user_can( 'upload_files' );
	}

	/**
	 * Permission gate for catalog proxy routes.
	 *
	 * `edit_posts` matches the capability required to use the block editor.
	 * Subscribers/readers without edit access shouldn't be hitting these
	 * routes even though they read public-ish catalog data — the routes
	 * exist to serve the editor UI, not as a public API.
	 */
	public static function catalog_permission() {
		return current_user_can( 'edit_posts' );
	}

	/**
	 * GET /studio — bootstrap proxy.
	 *
	 * See docs/SPEC-design-library-rest-api.md §2.6 + §11.
	 */
	public static function studio_callback( $request ) {
		unset( $request );
		return self::proxy_get(
			'studio',
			self::REST_STUDIO,
			array(),
			self::CACHE_PREFIX . 'studio'
		);
	}

	/**
	 * GET /patterns — paginated list proxy.
	 *
	 * Query params are normalized + sorted before hashing so equivalent
	 * queries map to the same cache key regardless of param order.
	 */
	public static function patterns_callback( $request ) {
		$query = self::normalize_query( $request->get_query_params() );
		ksort( $query );
		$cache_key = self::CACHE_PREFIX . 'patterns_' . md5( wp_json_encode( $query ) );
		return self::proxy_get(
			'patterns',
			self::REST_PATTERNS,
			$query,
			$cache_key
		);
	}

	/**
	 * GET /patterns/{id} — detail proxy.
	 */
	public static function pattern_detail_callback( $request ) {
		$id        = (string) $request->get_param( 'id' );
		$cache_key = self::CACHE_PREFIX . 'pattern_' . $id;
		return self::proxy_get(
			'pattern',
			self::REST_PATTERNS . '/' . rawurlencode( $id ),
			array(),
			$cache_key
		);
	}

	/**
	 * POST /patterns/{id}/preview — preview fallback proxy.
	 *
	 * Forwarded as POST per SPEC §3.6 — Studio side may run a lazy
	 * preview render that's too heavy for an unsafe GET.
	 */
	public static function pattern_preview_callback( $request ) {
		$id        = (string) $request->get_param( 'id' );
		$cache_key = self::CACHE_PREFIX . 'preview_' . $id;
		$path      = '/patterns/' . rawurlencode( $id ) . '/preview';
		return self::proxy_request(
			'preview',
			'POST',
			$path,
			array(),
			$cache_key
		);
	}

	/**
	 * Strip empty values + reserved REST params from the query bag so
	 * `?category=&page=1` and `?page=1` hash to the same key.
	 *
	 * @param array $query Raw query params from the REST request.
	 * @return array Normalized array, keys lowercased + empties dropped.
	 */
	protected static function normalize_query( $query ) {
		// WP_REST_Request includes its own routing params ('_locale', etc.)
		// when get_query_params() is called — drop everything that isn't
		// in our declared args() list to keep cache keys stable.
		$allowed = array( 'category', 'theme', 'search', 'page', 'per_page', 'sort', 'include_pro' );
		$out     = array();
		foreach ( $allowed as $key ) {
			if ( ! isset( $query[ $key ] ) ) {
				continue;
			}
			$value = $query[ $key ];
			if ( '' === $value || null === $value ) {
				continue;
			}
			$out[ $key ] = $value;
		}
		return $out;
	}

	/**
	 * GET proxy convenience wrapper — see proxy_request().
	 */
	protected static function proxy_get( $kind, $path, $query, $cache_key ) {
		return self::proxy_request( $kind, 'GET', $path, $query, $cache_key );
	}

	/**
	 * Resolve the Studio API base URL.
	 *
	 * Accepts two forms in `BLOCKSIFY_DESIGN_LIBRARY_API_URL` (or the
	 * `blocksify_design_library_api_base` filter):
	 *
	 * 1. **Origin only** — e.g. `https://studio.example.com/`. Plugin
	 *    auto-appends the canonical Studio REST suffix
	 *    `/wp-json/blocksify-design-studio/v1` so the user only needs
	 *    to point at a WP site running the Studio plugin.
	 *
	 * 2. **Full REST base** — e.g.
	 *    `https://custom-proxy.example.com/api/v1`. Used as-is, no
	 *    transformation. Recognized when the URL already contains
	 *    `/wp-json/` or otherwise looks like a non-WP-root URL.
	 *
	 * Detection rule: if the URL's path is empty / `/` (bare origin),
	 * append the canonical Studio REST suffix. Otherwise the URL
	 * already encodes its own routing (full REST base, custom proxy,
	 * etc.) and is used as-is. This covers every WP-shaped target
	 * without ceremony while letting power users point at non-WP
	 * proxies that own their own URL shape.
	 *
	 * @return string Untrailing-slashed, ready for `$base . $path` concat.
	 */
	protected static function resolve_api_base() {
		$raw = (string) apply_filters(
			'blocksify_design_library_api_base',
			defined( 'BLOCKSIFY_DESIGN_LIBRARY_API_URL' )
				? BLOCKSIFY_DESIGN_LIBRARY_API_URL
				: ''
		);
		$base = untrailingslashit( trim( $raw ) );
		if ( '' === $base ) {
			return '';
		}
		// Bare origin (no path beyond `/`) → append canonical suffix.
		// Anything with a path segment is assumed to be a full base.
		$path = (string) wp_parse_url( $base, PHP_URL_PATH );
		if ( '' === $path || '/' === $path ) {
			return $base . self::STUDIO_REST_SUFFIX;
		}
		return $base;
	}

	/**
	 * Run a cached proxy request against Studio (B).
	 *
	 * Cache hit → return cached body. Miss → wp_remote_request to B,
	 * cache 2xx response for the per-kind TTL, forward 4xx as-is,
	 * surface 5xx + unreachable as WP_Error.
	 *
	 * @param string $kind      One of 'studio'|'patterns'|'pattern'|'preview' — used for the cache TTL filter.
	 * @param string $method    'GET' or 'POST'.
	 * @param string $path      Path relative to BLOCKSIFY_DESIGN_LIBRARY_API_URL (with leading slash).
	 * @param array  $query     Query parameters appended to the URL.
	 * @param string $cache_key Transient key (already namespaced with CACHE_PREFIX).
	 * @return array|WP_Error Forwarded body on success, WP_Error on upstream failure.
	 */
	protected static function proxy_request( $kind, $method, $path, $query, $cache_key ) {
		// TEMP debug bypass — `BLOCKSIFY_DESIGN_LIBRARY_DISABLE_CACHE` is a
		// debugging escape hatch that forces every proxy call to skip the
		// transient layer (both read AND write) so contract / shape work
		// can iterate without the 3-min cache muddying results. Remove
		// the constant from wp-config when done.
		$bypass_cache = defined( 'BLOCKSIFY_DESIGN_LIBRARY_DISABLE_CACHE' )
			&& BLOCKSIFY_DESIGN_LIBRARY_DISABLE_CACHE;

		if ( ! $bypass_cache ) {
			$cached = get_transient( $cache_key );
			if ( false !== $cached ) {
				return rest_ensure_response( $cached );
			}
		}

		$base = self::resolve_api_base();

		// Guard against empty base — avoids wp_remote_request being handed
		// a relative path that would resolve to the current site (which
		// would loop back to this same route).
		if ( '' === $base ) {
			return new WP_Error(
				'blocksify_design_library_no_api_url',
				__( 'Design Library API base URL is not configured.', 'blocksify' ),
				array( 'status' => 500 )
			);
		}

		$url = $base . $path;
		if ( ! empty( $query ) ) {
			$url = add_query_arg( $query, $url );
		}

		$license = (string) apply_filters( 'blocksify_design_library_license_token', '' );

		$headers = array(
			'Accept'          => 'application/json',
			'Accept-Language' => str_replace( '_', '-', get_user_locale() ),
		);
		if ( '' !== $license ) {
			$headers['Authorization'] = 'Bearer ' . $license;
		}

		$args = apply_filters(
			'blocksify_design_library_proxy_args',
			array(
				'method'  => $method,
				'timeout' => 10,
				'headers' => $headers,
			),
			$kind,
			$path
		);

		$response = wp_remote_request( $url, $args );

		if ( is_wp_error( $response ) ) {
			return new WP_Error(
				'blocksify_design_library_upstream_unreachable',
				$response->get_error_message(),
				array( 'status' => 504 )
			);
		}

		$status = (int) wp_remote_retrieve_response_code( $response );
		$body   = wp_remote_retrieve_body( $response );
		$data   = '' === $body ? null : json_decode( $body, true );

		if ( $status >= 200 && $status < 300 ) {
			$ttl = (int) apply_filters(
				'blocksify_design_library_cache_ttl',
				self::DEFAULT_CACHE_TTL,
				$kind
			);
			if ( $ttl > 0 && ! $bypass_cache ) {
				set_transient( $cache_key, $data, $ttl );
			}
			return rest_ensure_response( $data );
		}

		// Forward 4xx as-is so the frontend can branch on Studio's own error
		// codes (license expired, pattern gone, etc.). Falls back to a
		// generic upstream-error wrapper when B's body isn't JSON.
		if ( $status >= 400 && $status < 500 ) {
			$code    = is_array( $data ) && isset( $data['code'] )
				? (string) $data['code']
				: 'blocksify_design_library_upstream_client_error';
			$message = is_array( $data ) && isset( $data['message'] )
				? (string) $data['message']
				: __( 'Upstream rejected the request.', 'blocksify' );
			return new WP_Error( $code, $message, array( 'status' => $status, 'upstream' => $data ) );
		}

		return new WP_Error(
			'blocksify_design_library_upstream_error',
			sprintf(
				/* translators: %d: HTTP status code */
				__( 'Upstream returned status %d.', 'blocksify' ),
				$status
			),
			array( 'status' => 502, 'upstream_status' => $status, 'upstream' => $data )
		);
	}

	/**
	 * Tracks the last flush_cache() row count so the dashboard's
	 * `blocksify_dashboard_clear_cache_count` filter can report it
	 * without re-querying. Each cleared row covers 2 rows (the value
	 * and its sibling `_timeout_` row); we divide by 2 in the contribute
	 * step so the count reports cache entries, not raw option rows.
	 *
	 * @var int
	 */
	protected static $last_flush_rows = 0;

	/**
	 * Flush all Design Library transients.
	 *
	 * Wired into `blocksify_dashboard_clear_cache` (see boot()) so the
	 * unified dashboard "Clear cache" button covers our cache layer.
	 * Wipes both `_transient_bsy_dl_*` and `_transient_timeout_bsy_dl_*`
	 * rows in one query.
	 *
	 * Fires `blocksify_design_library_cache_flushed` after the delete so
	 * consumers (logging, downstream caches) can react.
	 *
	 * @return int Number of option rows removed (value + timeout pairs).
	 */
	public static function flush_cache() {
		global $wpdb;
		// phpcs:disable WordPress.DB.DirectDatabaseQuery
		$deleted = (int) $wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
				$wpdb->esc_like( '_transient_' . self::CACHE_PREFIX ) . '%',
				$wpdb->esc_like( '_transient_timeout_' . self::CACHE_PREFIX ) . '%'
			)
		);
		// phpcs:enable
		self::$last_flush_rows = $deleted;
		do_action( 'blocksify_design_library_cache_flushed', $deleted );
		return $deleted;
	}

	/**
	 * Contribute Design Library's cleared count to the dashboard
	 * notice. Each cache entry occupies two rows (value + timeout) so
	 * we divide by 2 to report logical entries.
	 *
	 * @param int $count Running total from other subsystems.
	 * @return int Updated total.
	 */
	public static function contribute_cleared_count( $count ) {
		$entries = self::$last_flush_rows > 0
			? (int) ceil( self::$last_flush_rows / 2 )
			: 0;
		self::$last_flush_rows = 0;
		return (int) $count + $entries;
	}

	/**
	 * Sideload an external image into the user's media library.
	 *
	 * Dedup: if an attachment already carries the same `META_KEY` value,
	 * return it without downloading again. Same-session dedup also lives
	 * in the JS module cache; this server-side check covers reloads + new
	 * editor sessions for the same image.
	 *
	 * Failure modes: `download_url` returns WP_Error on HTTP/network
	 * failure; `media_handle_sideload` returns WP_Error on file/MIME
	 * rejection. Both surface to the JS caller, which falls back to
	 * hotlink for that image and continues inserting the rest.
	 *
	 * @param WP_REST_Request $request REST request with `url` + optional `alt`.
	 * @return array|WP_Error
	 */
	public static function sideload_callback( $request ) {
		$url = (string) $request->get_param( 'url' );
		$alt = (string) $request->get_param( 'alt' );

		if ( ! wp_http_validate_url( $url ) ) {
			return new WP_Error(
				'blocksify_design_library_invalid_url',
				__( 'Invalid image URL.', 'blocksify' ),
				array( 'status' => 400 )
			);
		}

		// Dedup: existing attachment with same META_KEY?
		$existing = get_posts(
			array(
				'post_type'      => 'attachment',
				'post_status'    => 'inherit',
				'posts_per_page' => 1,
				'no_found_rows'  => true,
				// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
				'meta_query'     => array(
					array(
						'key'   => self::META_KEY,
						'value' => $url,
					),
				),
				'fields'         => 'ids',
			)
		);
		if ( ! empty( $existing ) ) {
			$id = (int) $existing[0];
			return array(
				'id'         => $id,
				'source_url' => wp_get_attachment_url( $id ),
				'alt_text'   => get_post_meta( $id, '_wp_attachment_image_alt', true ),
				'cached'     => true,
			);
		}

		// Sideload. WP-CLI sometimes loads these lazily; require explicitly.
		require_once ABSPATH . 'wp-admin/includes/media.php';
		require_once ABSPATH . 'wp-admin/includes/file.php';
		require_once ABSPATH . 'wp-admin/includes/image.php';

		$tmp = download_url( $url );
		if ( is_wp_error( $tmp ) ) {
			return new WP_Error(
				'blocksify_design_library_download_failed',
				$tmp->get_error_message(),
				array( 'status' => 502 )
			);
		}

		$path     = wp_parse_url( $url, PHP_URL_PATH );
		$basename = $path ? basename( $path ) : '';
		if ( ! $basename || ! preg_match( '/\.[a-z0-9]+$/i', $basename ) ) {
			// Picsum-style URL (`/seed/X/W/H`) — derive a filename from
			// the seed segment so the media library shows something
			// human-readable instead of a hash. Falls back to a generic
			// `blocksify-pattern-image.jpg` if no seed.
			$seed = null;
			if ( $path && preg_match( '#/seed/([^/]+)#', $path, $m ) ) {
				$seed = $m[1];
			}
			$ext      = self::guess_extension( $tmp );
			$basename = ( $seed ?: 'blocksify-pattern-image' ) . '.' . $ext;
		}

		$file = array(
			'name'     => $basename,
			'tmp_name' => $tmp,
		);

		// Derive a human-readable title from the basename (strip ext, swap
		// separators for spaces, title-case). `media_handle_sideload`'s
		// default title fallback comes back empty on this WP + SQLite
		// pairing (verified empirically — attachments end up with empty
		// `post_title` so the media library renders "uploading..."
		// indefinitely). Passing `post_title` via `$post_data` makes the
		// derivation deterministic + survives whatever upstream changes
		// happen to the default code path.
		$title_base = preg_replace( '/\.[^.]+$/', '', $basename );
		$title_base = str_replace( array( '-', '_' ), ' ', (string) $title_base );
		$title      = $title_base !== '' ? ucwords( $title_base ) : __( 'Pattern image', 'blocksify' );

		$attachment_id = media_handle_sideload(
			$file,
			0,
			$alt,
			array(
				'post_title' => $title,
			)
		);

		if ( is_wp_error( $attachment_id ) ) {
			@unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			return new WP_Error(
				'blocksify_design_library_sideload_failed',
				$attachment_id->get_error_message(),
				array( 'status' => 500 )
			);
		}

		update_post_meta( $attachment_id, self::META_KEY, esc_url_raw( $url ) );
		if ( $alt !== '' ) {
			update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $alt ) );
		}

		return array(
			'id'         => (int) $attachment_id,
			'source_url' => wp_get_attachment_url( $attachment_id ),
			'alt_text'   => $alt,
			'cached'     => false,
		);
	}

	/**
	 * Best-effort MIME → extension lookup. Used when the source URL
	 * has no usable filename (picsum, signed URLs, hash-based paths).
	 *
	 * @param string $file_path Path to the downloaded temp file.
	 * @return string Extension without leading dot.
	 */
	protected static function guess_extension( $file_path ) {
		if ( ! function_exists( 'wp_check_filetype' ) ) {
			require_once ABSPATH . 'wp-includes/functions.php';
		}
		$mime = function_exists( 'mime_content_type' )
			? mime_content_type( $file_path )
			: '';
		switch ( $mime ) {
			case 'image/jpeg':
				return 'jpg';
			case 'image/png':
				return 'png';
			case 'image/webp':
				return 'webp';
			case 'image/gif':
				return 'gif';
			case 'image/avif':
				return 'avif';
			case 'image/svg+xml':
				return 'svg';
			default:
				return 'jpg';
		}
	}
}
