<?php
/**
 * REST API for the unified Clanbite React admin.
 *
 * @package clanbite
 */

namespace Kernowdev\Clanbite\Admin;

defined( 'ABSPATH' ) || exit;


use Kernowdev\Clanbite\Extensions\Abstract_Settings;
use Kernowdev\Clanbite\Extensions\Loader;
use Kernowdev\Clanbite\Extensions\Skeleton;
use WP_Error;
use WP_REST_Request;
use WP_REST_Server;

/**
 * Registers `clanbite/v1/admin/*` routes.
 */
class Admin_Rest {

	/**
	 * @var array<string, Abstract_Settings>
	 */
	protected array $settings_by_option = array();

	/**
	 * @param array<string, Abstract_Settings> $settings_by_option Option key => settings handler.
	 */
	public function __construct( array $settings_by_option ) {
		$this->settings_by_option = $settings_by_option;
	}

	public function register_routes(): void {
		register_rest_route(
			'clanbite/v1',
			'/admin/bootstrap',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'rest_bootstrap' ),
				'permission_callback' => array( $this, 'can_manage' ),
			)
		);

		register_rest_route(
			'clanbite/v1',
			'/admin/settings/(?P<option_key>[a-z0-9_]+)',
			array(
				'methods'             => WP_REST_Server::EDITABLE,
				'callback'            => array( $this, 'rest_save_settings' ),
				'permission_callback' => array( $this, 'can_manage' ),
				'args'                => array(
					'option_key' => array(
						'required' => true,
						'type'     => 'string',
					),
				),
			)
		);

		register_rest_route(
			'clanbite/v1',
			'/admin/extensions',
			array(
				'methods'             => WP_REST_Server::EDITABLE,
				'callback'            => array( $this, 'rest_save_extensions' ),
				'permission_callback' => array( $this, 'can_manage' ),
			)
		);
	}

	public function can_manage(): bool {
		/**
		 * Filter: `clanbite_admin_rest_manage_capability` — primitive capability required for `clanbite/v1/admin/*` routes.
		 *
		 * @param string $capability Default `manage_options`.
		 */
		$capability = apply_filters( 'clanbite_admin_rest_manage_capability', 'manage_options' );
		if ( ! is_string( $capability ) || '' === $capability ) {
			$capability = 'manage_options';
		}

		return current_user_can( $capability );
	}

	/**
	 * Default icon picker strings (REST bootstrap + localized script data).
	 *
	 * @return array<string, string>
	 */
	public static function get_default_icon_picker_i18n(): array {
		$icon_picker_i18n = array(
			'title'        => __( 'Choose icon', 'clanbite' ),
			'none'         => __( 'No icon', 'clanbite' ),
			'noIcons'      => __( 'No icon packs registered yet.', 'clanbite' ),
			'chooseIcon'   => __( 'Choose icon', 'clanbite' ),
			'mediaLibrary' => __( 'Media Library…', 'clanbite' ),
			'clear'        => __( 'Clear', 'clanbite' ),
		);

		return (array) apply_filters( 'clanbite_admin_icon_picker_i18n', $icon_picker_i18n );
	}

	/**
	 * Icon packs for the unified admin (same data as REST `iconPacks`).
	 *
	 * @return array<int, array<string, mixed>>
	 */
	public static function get_unified_icon_packs(): array {
		return self::collect_bootstrap_icon_packs();
	}

	/**
	 * Load companion Icon_Packs classes when the plugin constants exist but autoload missed them (REST vs admin edge cases).
	 *
	 * @return void
	 */
	protected static function require_companion_icon_pack_classes(): void {
		if ( ! class_exists( \Kernowdev\ClanbitePoints\Points\Icon_Packs::class, false )
			&& defined( 'CLANBITE_POINTS_PATH' ) ) {
			$file = \CLANBITE_POINTS_PATH . 'inc/Points/Icon_Packs.php';
			if ( is_readable( $file ) ) {
				require_once $file;
			}
		}
		if ( ! class_exists( \Kernowdev\ClanbiteRanks\Ranks\Icon_Packs::class, false )
			&& defined( 'CLANBITE_RANKS_PATH' ) ) {
			$file = \CLANBITE_RANKS_PATH . 'inc/Ranks/Icon_Packs.php';
			if ( is_readable( $file ) ) {
				require_once $file;
			}
		}
	}

	/**
	 * @param string $id Sanitized pack id.
	 * @return string
	 */
	protected static function infer_icon_pack_scope_from_id( string $id ): string {
		$id = sanitize_key( $id );
		if ( '' === $id ) {
			return 'all';
		}

		/**
		 * Filter: `clanbite_admin_infer_icon_pack_scope` — default `all` when a pack row has no `scope`.
		 *
		 * @param string $scope Default scope.
		 * @param string $id    Sanitized pack id.
		 */
		$scope = apply_filters( 'clanbite_admin_infer_icon_pack_scope', 'all', $id );
		$scope = is_string( $scope ) ? sanitize_key( $scope ) : '';

		return '' !== $scope ? $scope : 'all';
	}

	/**
	 * Ensure each pack has a non-empty sanitized `scope` for the React admin.
	 *
	 * @param array<string, mixed> $pack Normalized pack row (`id`, `label`, `icons`, optional `scope`).
	 * @return array<string, mixed>
	 */
	protected static function attach_icon_pack_scope( array $pack ): array {
		if ( isset( $pack['scope'] ) && is_string( $pack['scope'] ) ) {
			$candidate = sanitize_key( $pack['scope'] );
			if ( '' !== $candidate ) {
				$pack['scope'] = $candidate;
				return $pack;
			}
		}
		$id            = sanitize_key( (string) ( $pack['id'] ?? '' ) );
		$pack['scope'] = self::infer_icon_pack_scope_from_id( $id );

		return $pack;
	}

	/**
	 * Normalize icon pack rows for JSON (numeric keys → JS array; same rules as companion Icon_Packs::get_icon_packs).
	 *
	 * @param array<int, array<string, mixed>> $packs Raw packs.
	 * @return array<int, array<string, mixed>>
	 */
	protected static function normalize_bootstrap_icon_packs( array $packs ): array {
		$out = array();
		foreach ( $packs as $pack ) {
			if ( ! is_array( $pack ) ) {
				continue;
			}
			$id    = sanitize_key( (string) ( $pack['id'] ?? '' ) );
			$label = sanitize_text_field( (string) ( $pack['label'] ?? '' ) );
			$icons = isset( $pack['icons'] ) && is_array( $pack['icons'] ) ? $pack['icons'] : array();
			if ( '' === $id || '' === $label || array() === $icons ) {
				continue;
			}
			$normalized_icons = array();
			foreach ( $icons as $icon ) {
				if ( ! is_array( $icon ) ) {
					continue;
				}
				$icon_id    = sanitize_key( (string) ( $icon['id'] ?? '' ) );
				$icon_label = sanitize_text_field( (string) ( $icon['label'] ?? $icon_id ) );
				$icon_url   = esc_url_raw( (string) ( $icon['url'] ?? '' ) );
				if ( '' === $icon_id || '' === $icon_url ) {
					continue;
				}
				$normalized_icons[] = array(
					'id'    => $icon_id,
					'label' => '' !== $icon_label ? $icon_label : $icon_id,
					'url'   => $icon_url,
				);
			}
			if ( array() === $normalized_icons ) {
				continue;
			}
			$row = array(
				'id'    => $id,
				'label' => $label,
				'icons' => $normalized_icons,
			);
			if ( isset( $pack['scope'] ) ) {
				$row['scope'] = $pack['scope'];
			}
			$out[] = self::attach_icon_pack_scope( $row );
		}
		return $out;
	}

	/**
	 * Last-resort starter packs built from companion plugin paths (skips `clanbite_*_icon_packs` filters).
	 *
	 * Used when every icon row was stripped (hostile filters, empty `get_icon_packs()`) but SVGs exist on disk.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	protected static function get_emergency_starter_packs_normalized(): array {
		$raw = array();

		if ( defined( 'CLANBITE_POINTS_FILE' ) ) {
			$check = dirname( CLANBITE_POINTS_FILE ) . '/assets/point-icons/starter/coin-bronze.svg';
			if ( is_readable( $check ) ) {
				$base = trailingslashit( plugins_url( 'assets/point-icons/starter', CLANBITE_POINTS_FILE ) );
				$raw[] = array(
					'id'    => 'clanbite-points-starter',
					'label' => __( 'Clanbite Points Starter', 'clanbite' ),
					'scope' => 'points',
					'icons' => array(
						array(
							'id'    => 'coin-bronze',
							'label' => __( 'Bronze Coin', 'clanbite' ),
							'url'   => $base . 'coin-bronze.svg',
						),
						array(
							'id'    => 'coin-silver',
							'label' => __( 'Silver Coin', 'clanbite' ),
							'url'   => $base . 'coin-silver.svg',
						),
						array(
							'id'    => 'coin-gold',
							'label' => __( 'Gold Coin', 'clanbite' ),
							'url'   => $base . 'coin-gold.svg',
						),
						array(
							'id'    => 'gem-blue',
							'label' => __( 'Blue Gem', 'clanbite' ),
							'url'   => $base . 'gem-blue.svg',
						),
						array(
							'id'    => 'gem-purple',
							'label' => __( 'Purple Gem', 'clanbite' ),
							'url'   => $base . 'gem-purple.svg',
						),
						array(
							'id'    => 'star-token',
							'label' => __( 'Star Token', 'clanbite' ),
							'url'   => $base . 'star-token.svg',
						),
					),
				);
			}
		}

		if ( defined( 'CLANBITE_RANKS_FILE' ) ) {
			$check = dirname( CLANBITE_RANKS_FILE ) . '/assets/rank-icons/starter/wood.svg';
			if ( is_readable( $check ) ) {
				$base = trailingslashit( plugins_url( 'assets/rank-icons/starter', CLANBITE_RANKS_FILE ) );
				$raw[] = array(
					'id'    => 'clanbite-starter',
					'label' => __( 'Clanbite Starter', 'clanbite' ),
					'scope' => 'ranks',
					'icons' => array(
						array(
							'id'    => 'wood',
							'label' => __( 'Wood', 'clanbite' ),
							'url'   => $base . 'wood.svg',
						),
						array(
							'id'    => 'bronze',
							'label' => __( 'Bronze', 'clanbite' ),
							'url'   => $base . 'bronze.svg',
						),
						array(
							'id'    => 'silver',
							'label' => __( 'Silver', 'clanbite' ),
							'url'   => $base . 'silver.svg',
						),
						array(
							'id'    => 'gold',
							'label' => __( 'Gold', 'clanbite' ),
							'url'   => $base . 'gold.svg',
						),
						array(
							'id'    => 'platinum',
							'label' => __( 'Platinum', 'clanbite' ),
							'url'   => $base . 'platinum.svg',
						),
						array(
							'id'    => 'diamond',
							'label' => __( 'Diamond', 'clanbite' ),
							'url'   => $base . 'diamond.svg',
						),
					),
				);
			}
		}

		return self::normalize_bootstrap_icon_packs( $raw );
	}

	/**
	 * Normalized icon packs for the unified admin (points + ranks starter SVGs, etc.).
	 *
	 * Merges `clanbite_admin_icon_packs` with packs from companion plugins when those
	 * classes exist, deduped by pack id. Filter-only results that are empty after
	 * normalization, or invalid associative shapes, still get starter packs from Points/Ranks.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	protected static function collect_bootstrap_icon_packs(): array {
		self::require_companion_icon_pack_classes();

		$raw = apply_filters( 'clanbite_admin_icon_packs', array() );
		if ( ! is_array( $raw ) ) {
			$raw = array();
		}
		// Force a JSON array (associative filter output breaks `Array.isArray` in the React admin).
		$raw = array_values( array_filter( $raw, 'is_array' ) );

		$from_filter = self::normalize_bootstrap_icon_packs( $raw );

		$seen   = array();
		$merged = array();
		foreach ( $from_filter as $pack ) {
			$id = sanitize_key( (string) ( $pack['id'] ?? '' ) );
			if ( '' === $id || isset( $seen[ $id ] ) ) {
				continue;
			}
			$seen[ $id ] = true;
			$merged[]    = $pack;
		}

		$fallback = array();
		if ( class_exists( \Kernowdev\ClanbitePoints\Points\Icon_Packs::class ) ) {
			$fallback = array_merge(
				$fallback,
				\Kernowdev\ClanbitePoints\Points\Icon_Packs::get_icon_packs()
			);
		}
		if ( class_exists( \Kernowdev\ClanbiteRanks\Ranks\Icon_Packs::class ) ) {
			$fallback = array_merge(
				$fallback,
				\Kernowdev\ClanbiteRanks\Ranks\Icon_Packs::get_icon_packs()
			);
		}
		$fallback_rows = array_values( array_filter( $fallback, 'is_array' ) );
		foreach ( self::normalize_bootstrap_icon_packs( $fallback_rows ) as $pack ) {
			$id = sanitize_key( (string) ( $pack['id'] ?? '' ) );
			if ( '' === $id || isset( $seen[ $id ] ) ) {
				continue;
			}
			$seen[ $id ] = true;
			$merged[]    = $pack;
		}

		if ( array() === $merged ) {
			$merged = self::get_emergency_starter_packs_normalized();
		}

		return $merged;
	}

	/**
	 * @return \WP_REST_Response|WP_Error
	 */
	public function rest_bootstrap() {
		$loader     = Loader::instance();
		$extensions = $loader->get_extensions();

		$values   = array();
		$schemas  = array();
		foreach ( $this->settings_by_option as $key => $handler ) {
			$values[ $key ]  = $handler->get_all();
			$schemas[ $key ] = $handler->export_rest_schema();
		}

		$data = array(
			'tabs'             => $this->build_tabs( $extensions ),
			'values'           => $values,
			'optionSchemas'    => $schemas,
			'extensions'       => $this->build_extensions_payload( $loader, $extensions ),
			'generalOptionKey' => General_Settings::OPTION_KEY,
			'iconPacks'        => self::get_unified_icon_packs(),
			'iconPickerI18n'   => self::get_default_icon_picker_i18n(),
			'plugin'           => array(
				'version' => (string) \clanbite()->get_version(),
			),
		);

		/**
		 * Filter: `clanbite_admin_rest_bootstrap` — full payload for `GET clanbite/v1/admin/bootstrap`.
		 *
		 * @param array<string, mixed> $data   Bootstrap body (`tabs`, `values`, `extensions`, …).
		 * @param Loader               $loader Extension loader instance.
		 */
		$data = (array) apply_filters( 'clanbite_admin_rest_bootstrap', $data, $loader );

		return rest_ensure_response( $data );
	}

	/**
	 * @param array<string, Skeleton> $extensions
	 * @return array<int, array<string, mixed>>
	 */
	protected function build_tabs( array $extensions ): array {
		$tabs = array(
			array(
				'id'    => 'general',
				'type'  => 'general',
				'label' => __( 'General', 'clanbite' ),
			),
			array(
				'id'    => 'extensions',
				'type'  => 'extensions',
				'label' => __( 'Extensions', 'clanbite' ),
			),
		);

		$groups_key = Groups_Settings::OPTION_KEY;
		if ( isset( $this->settings_by_option[ $groups_key ] ) ) {
			$groups_handler = $this->settings_by_option[ $groups_key ];
			$groups_schema  = $groups_handler->export_rest_schema();
			if ( ! empty( $groups_schema ) ) {
				$tabs[] = array(
					'id'            => 'core-groups',
					'type'          => 'extension',
					'label'         => __( 'Groups', 'clanbite' ),
					'sectionGroups' => array(
						array(
							'kind'      => 'primary',
							'name'      => __( 'Groups', 'clanbite' ),
							'slug'      => 'groups',
							'optionKey' => $groups_key,
							'sections'  => $groups_schema,
						),
					),
				);
			}
		}

		$by_slug = $extensions;

		$children_by_parent = array();
		foreach ( $extensions as $slug => $ext ) {
			if ( ! $ext instanceof Skeleton ) {
				continue;
			}
			$parent = $ext->parent_slug ?? '';
			if ( '' !== (string) $parent ) {
				$children_by_parent[ $parent ][] = $slug;
			}
		}

		$added = array();

		$loader = Loader::instance();

		foreach ( $extensions as $slug => $ext ) {
			if ( ! $ext instanceof Skeleton ) {
				continue;
			}
			if ( '' !== (string) ( $ext->parent_slug ?? '' ) ) {
				continue;
			}
			if ( ! $loader->is_extension_installed( $slug ) ) {
				continue;
			}

			$groups = $this->section_groups_for_extension_tree( $slug, $ext, $by_slug, $children_by_parent, $loader );
			if ( empty( $groups ) ) {
				continue;
			}

			$tabs[]  = array(
				'id'             => 'ext-' . $slug,
				'type'           => 'extension',
				'label'          => $ext->name,
				'extensionSlug'  => $slug,
				'sectionGroups'  => $groups,
			);
			$added[ $slug ] = true;
		}

		// Orphan child extensions (unknown parent): own tab.
		foreach ( $extensions as $slug => $ext ) {
			if ( ! $ext instanceof Skeleton ) {
				continue;
			}
			$parent = (string) ( $ext->parent_slug ?? '' );
			if ( '' === $parent || isset( $by_slug[ $parent ] ) ) {
				continue;
			}
			if ( ! $loader->is_extension_installed( $slug ) ) {
				continue;
			}
			$admin = $ext->get_settings_admin();
			if ( ! $admin instanceof Abstract_Settings ) {
				continue;
			}
			if ( isset( $added[ $slug ] ) ) {
				continue;
			}

			$tabs[] = array(
				'id'            => 'ext-' . $slug,
				'type'          => 'extension',
				'label'         => $ext->name,
				'extensionSlug' => $slug,
				'sectionGroups' => array(
					array(
						'kind'      => 'child',
						'name'      => $ext->name,
						'slug'      => $slug,
						'optionKey' => $admin->get_option_key(),
						'sections'  => $admin->export_rest_schema(),
					),
				),
			);
			$added[ $slug ] = true;
		}

		/**
		 * Filter: `clanbite_admin_rest_tabs`.
		 *
		 * @param array<int, array<string, mixed>> $tabs   Tab descriptors (`type`: `general`|`extensions`|`extension`|`integration`).
		 * @param Loader                           $loader Extension loader.
		 */
		$filtered_tabs = apply_filters( 'clanbite_admin_rest_tabs', $tabs, $loader );

		if ( ! is_array( $filtered_tabs ) ) {
			return $tabs;
		}

		return array_values( array_filter( $filtered_tabs, 'is_array' ) );
	}

	/**
	 * @param array<string, Skeleton>           $by_slug
	 * @param array<string, array<int, string>> $children_by_parent
	 * @param Loader                              $loader Extension install state.
	 * @return array<int, array<string, mixed>>
	 */
	protected function section_groups_for_extension_tree( string $root_slug, Skeleton $root, array $by_slug, array $children_by_parent, Loader $loader ): array {
		$groups = array();

		$root_admin = $root->get_settings_admin();
		if ( $root_admin instanceof Abstract_Settings ) {
			$schema = $root_admin->export_rest_schema();
			if ( ! empty( $schema ) ) {
				$groups[] = array(
					'kind'      => 'primary',
					'name'      => $root->name,
					'slug'      => $root_slug,
					'optionKey' => $root_admin->get_option_key(),
					'sections'  => $schema,
				);
			}
		}

		if ( empty( $children_by_parent[ $root_slug ] ) ) {
			return $groups;
		}

		foreach ( $children_by_parent[ $root_slug ] as $child_slug ) {
			if ( ! $loader->is_extension_installed( $child_slug ) ) {
				continue;
			}
			if ( ! isset( $by_slug[ $child_slug ] ) ) {
				continue;
			}
			$child = $by_slug[ $child_slug ];
			if ( ! $child instanceof Skeleton ) {
				continue;
			}
			$c_admin = $child->get_settings_admin();
			if ( ! $c_admin instanceof Abstract_Settings ) {
				continue;
			}
			$c_schema = $c_admin->export_rest_schema();
			if ( empty( $c_schema ) ) {
				continue;
			}
			$groups[] = array(
				'kind'      => 'child',
				'name'      => $child->name,
				'slug'      => $child_slug,
				'optionKey' => $c_admin->get_option_key(),
				'sections'  => $c_schema,
			);
		}

		return $groups;
	}

	/**
	 * @param array<string, Skeleton> $extensions
	 * @return array<int, array<string, mixed>> Each item includes `isCoreBundled` (main-package extensions only).
	 */
	protected function build_extensions_payload( Loader $loader, array $extensions ): array {
		$installed   = $loader->get_installed_extensions();
		$official    = $loader->get_official_extensions();
		$extensions  = $this->sort_extensions_by_dependencies( $extensions );
		$out         = array();

		$required    = Loader::get_required_extension_slugs();
		$core_bundle = Loader::get_core_bundled_extension_slugs();

		foreach ( $extensions as $slug => $ext ) {
			if ( ! $ext instanceof Skeleton ) {
				continue;
			}
			$row = array(
				'slug'                   => $slug,
				'name'                   => $ext->name,
				'description'            => $ext->description,
				'version'                => $ext->version,
				'type'                   => $ext->type,
				'parentSlug'             => (string) ( $ext->parent_slug ?? '' ),
				'requires'               => array_values( $ext->requires ),
				'requiresClanbite'       => $ext->get_requires_clanbite_version(),
				'meetsClanbiteVersion'   => $ext->meets_clanbite_version_requirement(),
				'isOfficial'             => isset( $official[ $slug ] ),
				'isCoreBundled'          => in_array( $slug, $core_bundle, true ),
				'isInstalled'            => isset( $installed[ $slug ] ),
				'canInstall'             => $ext->can_install(),
				'isRequired'             => in_array( $slug, $required, true ),
			);

			/**
			 * Filter: `clanbite_extension_capabilities` — optional capability hints for an extension row in admin bootstrap JSON.
			 *
			 * Use associative keys meaningful to your integration (e.g. `manage`, `configure`).
			 *
			 * @param array<string, mixed> $capabilities Default empty; merged keys surface on the extension row as `capabilities`.
			 * @param string               $slug           Extension slug.
			 * @param Skeleton             $extension      Extension instance.
			 */
			$row['capabilities'] = (array) apply_filters( 'clanbite_extension_capabilities', array(), $slug, $ext );

			$out[] = $row;
		}

		return $out;
	}

	/**
	 * Topological order by `requires` (dependencies before dependents), then slug.
	 *
	 * Extensions listed in {@see Loader::get_required_extension_slugs()} are ordered first among
	 * nodes with the same readiness (e.g. `cp_players` stays at the top).
	 *
	 * @param array<string, Skeleton> $extensions Registered extensions.
	 * @return array<string, Skeleton>
	 */
	protected function sort_extensions_by_dependencies( array $extensions ): array {
		$valid = array();
		foreach ( $extensions as $slug => $extension ) {
			if ( $extension instanceof Skeleton ) {
				$valid[ sanitize_key( (string) $slug ) ] = $extension;
			}
		}

		if ( $valid === array() ) {
			return array();
		}

		$slugs        = array_keys( $valid );
		$in_degree    = array_fill_keys( $slugs, 0 );
		$dependents   = array();
		$required     = Loader::get_required_extension_slugs();
		$required_rank = array_flip( $required );

		foreach ( $valid as $slug => $extension ) {
			foreach ( $extension->requires as $req ) {
				$req = sanitize_key( (string) $req );
				if ( '' === $req || $req === $slug || ! isset( $valid[ $req ] ) ) {
					continue;
				}
				if ( ! isset( $dependents[ $req ] ) ) {
					$dependents[ $req ] = array();
				}
				$dependents[ $req ][] = $slug;
				++$in_degree[ $slug ];
			}
		}

		$queue = array_values(
			array_filter(
				$slugs,
				static function ( string $s ) use ( $in_degree ): bool {
					return 0 === ( $in_degree[ $s ] ?? 1 );
				}
			)
		);

		$sorted = array();
		while ( $queue !== array() ) {
			usort(
				$queue,
				static function ( string $a, string $b ) use ( $required_rank ): int {
					$ra = array_key_exists( $a, $required_rank ) ? (int) $required_rank[ $a ] : 1000;
					$rb = array_key_exists( $b, $required_rank ) ? (int) $required_rank[ $b ] : 1000;
					if ( $ra !== $rb ) {
						return $ra <=> $rb;
					}
					return strcmp( $a, $b );
				}
			);

			while ( $queue !== array() && isset( $sorted[ $queue[0] ] ) ) {
				array_shift( $queue );
			}
			if ( $queue === array() ) {
				break;
			}

			$slug = array_shift( $queue );
			if ( null === $slug ) {
				break;
			}
			$sorted[ $slug ] = $valid[ $slug ];

			foreach ( $dependents[ $slug ] ?? array() as $dep ) {
				--$in_degree[ $dep ];
				if ( 0 === $in_degree[ $dep ] && ! in_array( $dep, $queue, true ) ) {
					$queue[] = $dep;
				}
			}
		}

		// Cycle or missing edges: append remaining in stable slug order.
		foreach ( $slugs as $slug ) {
			if ( ! isset( $sorted[ $slug ] ) ) {
				$sorted[ $slug ] = $valid[ $slug ];
			}
		}

		return $sorted;
	}

	/**
	 * @param WP_REST_Request $request Request.
	 * @return \WP_REST_Response|WP_Error
	 */
	public function rest_save_settings( WP_REST_Request $request ) {
		$key = sanitize_key( (string) $request->get_param( 'option_key' ) );

		if ( ! isset( $this->settings_by_option[ $key ] ) ) {
			return new WP_Error( 'clanbite_unknown_option', __( 'Unknown settings group.', 'clanbite' ), array( 'status' => 404 ) );
		}

		$body  = $request->get_json_params();
		$input = is_array( $body ) ? $body : array();

		$handler = $this->settings_by_option[ $key ];
		$saved   = $handler->save_from_input( $input );

		return rest_ensure_response(
			array(
				'optionKey' => $key,
				'values'    => $saved,
			)
		);
	}

	/**
	 * Reduce a requested extension list to slugs that can be enabled together in one save.
	 *
	 * Repeats require-closure pruning and per-extension `can_install_with_slugs()` checks until stable,
	 * so dependencies toggled in the same request are visible to each other before persistence.
	 *
	 * @param array<string, Skeleton> $available Registered extensions.
	 * @param array<int, string>      $requested Slugs the user submitted as enabled.
	 * @return array<int, string>
	 */
	protected function resolve_installable_requested_slugs( array $available, array $requested ): array {
		$slugs = array_values( array_unique( array_map( 'sanitize_key', $requested ) ) );
		$guard = 0;
		while ( $guard++ < 100 ) {
			$prev_count = count( $slugs );
			$set        = array_fill_keys( $slugs, true );

			$slugs = array_values(
				array_filter(
					$slugs,
					static function ( string $slug ) use ( $available, $set ): bool {
						if ( ! isset( $available[ $slug ] ) || ! $available[ $slug ] instanceof Skeleton ) {
							return false;
						}
						$ext = $available[ $slug ];
						foreach ( $ext->requires as $require ) {
							if ( ! isset( $set[ $require ] ) ) {
								return false;
							}
						}
						return true;
					}
				)
			);

			$slugs = array_values(
				array_filter(
					$slugs,
					static function ( string $slug ) use ( $available, $slugs ): bool {
						$ext = $available[ $slug ] ?? null;

						return $ext instanceof Skeleton && $ext->can_install_with_slugs( $slugs );
					}
				)
			);

			if ( count( $slugs ) === $prev_count ) {
				break;
			}
		}

		return $slugs;
	}

	/**
	 * @param WP_REST_Request $request Request.
	 * @return \WP_REST_Response|WP_Error
	 */
	public function rest_save_extensions( WP_REST_Request $request ) {
		$body = $request->get_json_params();
		if ( ! is_array( $body ) || ! isset( $body['installed'] ) || ! is_array( $body['installed'] ) ) {
			return new WP_Error( 'clanbite_bad_payload', __( 'Invalid extensions payload.', 'clanbite' ), array( 'status' => 400 ) );
		}

		$requested_raw = array_map( 'sanitize_key', $body['installed'] );
		$required      = Loader::get_required_extension_slugs();

		$loader    = Loader::instance();
		$available = $loader->get_extensions();

		$requested = array_values(
			array_unique(
				array_merge( $required, $requested_raw )
			)
		);

		$prev_slugs = array_keys( $loader->get_installed_extensions() );
		foreach ( array_diff( $prev_slugs, $requested ) as $removed_slug ) {
			if ( in_array( (string) $removed_slug, $required, true ) ) {
				continue;
			}
			$loader->uninstall_extension( (string) $removed_slug );
		}

		$resolved = $this->resolve_installable_requested_slugs( $available, $requested );

		$new = array();
		foreach ( $resolved as $slug ) {
			if ( ! isset( $available[ $slug ] ) ) {
				continue;
			}
			$ext = $available[ $slug ];
			if ( ! $ext instanceof Skeleton ) {
				continue;
			}
			$new[ $slug ] = array(
				'version' => $ext->version ?? '1.0.0',
			);
		}

		$new = (array) apply_filters( 'clanbite_validate_installed_extensions', $new, $requested, $available );

		foreach ( $required as $req_slug ) {
			if ( isset( $available[ $req_slug ] ) && $available[ $req_slug ] instanceof Skeleton ) {
				$new[ $req_slug ] = array(
					'version' => (string) ( $available[ $req_slug ]->version ?? '1.0.0' ),
				);
			}
		}

		if ( is_multisite() && is_network_admin() ) {
			update_site_option( 'clanbite_installed_extensions', $new );
		} else {
			update_option( 'clanbite_installed_extensions', $new );
		}

		// Defer regeneration to the next request so `init` runs with the new install list (rewrite rules match registered routes).
		delete_option( 'rewrite_rules' );

		return rest_ensure_response( array( 'installed' => $new ) );
	}
}
